From c57ef4857d5fd7bdee12701dc790ade286468cb1 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Fri, 6 Nov 2026 19:59:44 +0200 Subject: [PATCH 1/5] ci(docs): build the docs site in Actions and stop committing generated docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop tracking docs build output and let .github/workflows/docs.yml build and publish it, so docs PRs stop conflicting on docs/search-index.json and docs/llms-full.txt (output computed from every page, never from the page the PR touches). - remove docs/*.html, docs/.nojekyll, docs/assets/, docs/llms.txt, docs/llms-full.txt, docs/search-index.json from the tree (history kept) and gitignore them; main now tracks only docs/*.md + docs/nav.json - docs.yml: build on push to main for docs/** + workflow_dispatch, concurrency group docs, contents:read on build, pages:write/id-token:write only on deploy, upload-pages-artifact + deploy-pages, no commit-back, explicit --repo-url, and if: github.ref == 'refs/heads/main' on both jobs so a manual run cannot publish a branch - docs-guard.yml: fail a PR that adds/modifies/copies/renames any docs path except **/*.md and nav.json (deletions allowed — the migration is a deletion); -z diff parsing so non-ASCII and newline-containing paths cannot slip through - CONTRIBUTING.md/AGENTS.md: edit the .md, never run pi-docsite in a PR; drop the now-dead generated-docs globs from .pr_agent.toml Refs #2873 Assisted-by: PI (rits/deepseek-ai/DeepSeek-V4.1-Flash-1017) Signed-off-by: Meni Yakove --- .github/workflows/docs-guard.yml | 78 + .github/workflows/docs.yml | 61 + .gitignore | 11 + .pr_agent.toml | 10 - AGENTS.md | 2 +- CONTRIBUTING.md | 35 +- docs/.nojekyll | 0 docs/assets/callouts.js | 26 - docs/assets/codelabels.js | 75 - docs/assets/copy.js | 77 - docs/assets/github.js | 38 - docs/assets/scrollspy.js | 49 - docs/assets/search.js | 186 - docs/assets/style.css | 1618 --- docs/assets/theme.js | 22 - docs/class-generator-cli.html | 1643 ---- docs/common-patterns.html | 1212 --- docs/connecting-to-clusters.html | 722 -- docs/creating-and-managing-resources.html | 890 -- docs/editing-resources-temporarily.html | 683 -- docs/environment-variables.html | 972 -- docs/error-handling.html | 1278 --- docs/generating-resource-classes.html | 716 -- docs/index.html | 491 - docs/llms-full.txt | 10319 -------------------- docs/llms.txt | 36 - docs/managing-resource-lists.html | 560 -- docs/mcp-server-integration.html | 753 -- docs/pod-execution-and-logs.html | 765 -- docs/querying-resources.html | 1028 -- docs/quickstart.html | 652 -- docs/resource-api.html | 2509 ----- docs/resource-class-hierarchy.html | 671 -- docs/schema-validation-internals.html | 788 -- docs/search-index.json | 1 - docs/testing-without-cluster.html | 904 -- docs/validating-resources.html | 634 -- docs/waiting-for-conditions.html | 851 -- docs/working-with-events.html | 899 -- docs/working-with-virtual-machines.html | 1192 --- 40 files changed, 175 insertions(+), 33282 deletions(-) create mode 100644 .github/workflows/docs-guard.yml create mode 100644 .github/workflows/docs.yml delete mode 100644 docs/.nojekyll delete mode 100644 docs/assets/callouts.js delete mode 100644 docs/assets/codelabels.js delete mode 100644 docs/assets/copy.js delete mode 100644 docs/assets/github.js delete mode 100644 docs/assets/scrollspy.js delete mode 100644 docs/assets/search.js delete mode 100644 docs/assets/style.css delete mode 100644 docs/assets/theme.js delete mode 100644 docs/class-generator-cli.html delete mode 100644 docs/common-patterns.html delete mode 100644 docs/connecting-to-clusters.html delete mode 100644 docs/creating-and-managing-resources.html delete mode 100644 docs/editing-resources-temporarily.html delete mode 100644 docs/environment-variables.html delete mode 100644 docs/error-handling.html delete mode 100644 docs/generating-resource-classes.html delete mode 100644 docs/index.html delete mode 100644 docs/llms-full.txt delete mode 100644 docs/llms.txt delete mode 100644 docs/managing-resource-lists.html delete mode 100644 docs/mcp-server-integration.html delete mode 100644 docs/pod-execution-and-logs.html delete mode 100644 docs/querying-resources.html delete mode 100644 docs/quickstart.html delete mode 100644 docs/resource-api.html delete mode 100644 docs/resource-class-hierarchy.html delete mode 100644 docs/schema-validation-internals.html delete mode 100644 docs/search-index.json delete mode 100644 docs/testing-without-cluster.html delete mode 100644 docs/validating-resources.html delete mode 100644 docs/waiting-for-conditions.html delete mode 100644 docs/working-with-events.html delete mode 100644 docs/working-with-virtual-machines.html diff --git a/.github/workflows/docs-guard.yml b/.github/workflows/docs-guard.yml new file mode 100644 index 0000000000..8c93b0499c --- /dev/null +++ b/.github/workflows/docs-guard.yml @@ -0,0 +1,78 @@ +name: Docs Guard + +on: pull_request + +permissions: + contents: read + +jobs: + no-generated-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Reject generated docs in the PR + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + + # An absent base ref must fail loudly: falling through to a diff that errors, or to an + # empty diff, would report the guard as passing without having checked anything. + if [ -z "$BASE_REF" ]; then + echo "::error::BASE_REF is empty — cannot compute the PR diff." + exit 1 + fi + if ! git rev-parse --verify --quiet "origin/${BASE_REF}" > /dev/null; then + echo "::error::origin/${BASE_REF} is not a known ref (shallow clone or absent base)." + exit 1 + fi + + # The diff is captured to a file first so its exit status is checked on its own: piping + # git into grep without pipefail reports "no match" when the diff never ran, so an absent + # check would look exactly like a passing one. + diff_file="$(mktemp)" + trap 'rm -f "$diff_file"' EXIT + if ! git diff --name-status -z --diff-filter=AMR -M "origin/${BASE_REF}...HEAD" > "$diff_file"; then + echo "::error::git diff against origin/${BASE_REF} failed — refusing to report a pass." + exit 1 + fi + + # Only added/modified/renamed/copied files are a violation. A DELETION is the migration + # itself (removing the committed output) and must pass, or the PR that fixes the rule can + # never satisfy it. Renames are checked on BOTH sides, so docs/foo.md -> docs/index.html + # is caught too. + # + # -z keeps git from quoting or escaping paths (that is core.quotePath, unrelated to the + # shell): quoted, an added docs/résumé.html arrives as "docs/r\303\251sum\303\251.html" + # and matches none of the patterns below — the guard would pass exactly the files it + # exists to reject. + # + # -z emits `status\0path\0` (`R100\0old\0new\0` for a rename), so status tokens are + # dropped by shape, not by column — there is no column left after NUL splitting. Fields + # are matched as they arrive and never re-joined with newlines: a path CAN itself contain + # a newline, and converting NUL -> newline splits `docs/evil\nx.html` into two lines that + # match none of the patterns, so the generated file would slip through. `read -d ''` fails + # at EOF, which only ends this while condition (set -e does not apply to a loop + # condition), so the last field is not dropped. + # + # Inverted allowlist: docs/**/*.md and docs/nav.json are the sources; everything else + # under docs/ is what .github/workflows/docs.yml builds. + offenders=() + while IFS= read -r -d '' field; do + [[ "$field" =~ ^[ACDMRTUXB][0-9]*$ ]] && continue + [[ "$field" == docs/* ]] || continue + [[ "$field" == docs/nav.json ]] && continue + [[ "$field" =~ ^docs/(.*/)?[^/]+\.md$ ]] && continue + offenders+=("$field") + done < "$diff_file" + + if [ "${#offenders[@]}" -gt 0 ]; then + echo "::error::Generated docs must not be committed — the site is built on main from the docs sources." + printf '%s\n' "${offenders[@]}" + echo "Everything under docs/ other than docs/**/*.md and docs/nav.json is build output of" + echo ".github/workflows/docs.yml — edit the markdown source instead." + exit 1 + fi + echo "No generated docs in the diff." diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..101a4efa65 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,61 @@ +name: Docs + +on: + push: + branches: [main] + paths: + # Any docs change, not just .md: the migration that removes the committed output touches + # only generated paths, and it must still deploy. + - "docs/**" + # The first deploy after Pages is switched to build_type=workflow has no push to ride on. + workflow_dispatch: + +concurrency: + group: docs + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + # workflow_dispatch accepts any ref, so without this guard `gh workflow run docs.yml --ref + # some-branch` would publish that branch's docs/ to the live Pages site. Deploying is main-only; + # a manual run is still how the first deploy after the Pages switch is triggered. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + # Building needs nothing but the sources: publish rights live on the deploy job alone, so + # whatever the build step runs can never reach Pages. + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - name: Build site from docs/*.md + # Always the newest release: docs should not wait on a bump commit, and this job holds no + # publish rights (they are on `deploy`), so the blast radius of a bad upstream upload is a + # failed build, not a compromised deployment. + # --repo-url is explicit on purpose: pi-docsite otherwise derives it from `origin`, and a + # build from a fork rewrites every repository link. + run: >- + uvx pi-docsite@latest --docs-dir docs + --repo-url https://github.com/RedHatQE/openshift-python-wrapper + --tagline "Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls" + - uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + # The only job that publishes. + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index e306125723..fde2132a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,14 @@ class_generator/schema/__resources-mappings.json # graft's local graph cache — regenerable, not committed (run `graft build`). /graft/ + +# Docs build output: generated by pi-docsite in .github/workflows/docs.yml and published by Pages. +# Only docs/*.md and docs/nav.json are tracked — never commit anything below. +# .nojekyll is the legacy branch-build marker; artifact deploys skip Jekyll, so it must not come +# back either (src: .github/workflows/docs-guard.yml rejects it). +docs/*.html +docs/.nojekyll +docs/assets/ +docs/llms.txt +docs/llms-full.txt +docs/search-index.json diff --git a/.pr_agent.toml b/.pr_agent.toml index cc277841dc..c69a7f0b11 100644 --- a/.pr_agent.toml +++ b/.pr_agent.toml @@ -10,13 +10,3 @@ push_commands = ["/agentic_review"] [review_agent] comments_location_policy = "both" inline_comments_severity_threshold = 1 - -[ignore] -glob = [ - "docs/*.html", - "docs/search-index.json", - "docs/llms.txt", - "docs/llms-full.txt", - "docs/assets/*.js", - "docs/assets/style.css", -] diff --git a/AGENTS.md b/AGENTS.md index 6683132a6d..f5c3330e87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Code between markers is auto-generated — do NOT modify manually. Use `class-ge - ✅ Always: use `uv` for Python ops, run `prek install` before first commit, add typing - ⚠️ Ask first: adding new dependencies, modifying base `Resource` class, changing CI -- 🚫 Never: use `python`/`pip` directly, edit generated code between markers, edit generated files in `docs/` (regenerate with pi-docsite instead) +- 🚫 Never: use `python`/`pip` directly, edit generated code between markers, commit generated files under `docs/` (never run pi-docsite in a PR — edit the `.md` and let the Action build it) ## Environment diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33689a77b4..f69c078f5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,13 +37,19 @@ prek install ## Documentation -The site under `docs/` is generated by [pi-docsite](https://github.com/myk-org/pi-config/tree/main/packages/pi-docsite). -Edit the Markdown sources, never the generated files: +The site under `docs/` is generated by [pi-docsite](https://github.com/myk-org/pi-config/tree/main/packages/pi-docsite) +and published by `.github/workflows/docs.yml` on every push to `main` that touches `docs/` +(https://redhatqe.github.io/openshift-python-wrapper/). + +`main` tracks only the sources: - sources: `docs/*.md` and the sidebar order in `docs/nav.json` -- generated (do not edit): `docs/*.html`, `docs/search-index.json`, `docs/llms.txt`, `docs/llms-full.txt`, `docs/assets/` -Rebuild the site with one command: +**Never run pi-docsite in a pull request.** Its output is not tracked — the Action builds it on +`main`. `.github/workflows/docs-guard.yml` rejects any PR that adds, modifies, copies or renames a +path under `docs/` other than `**/*.md` and `nav.json` (deletions are allowed). + +To build locally for debugging — the output is gitignored, so it never shows up in `git status`: ```bash uvx pi-docsite --docs-dir docs \ @@ -52,19 +58,26 @@ uvx pi-docsite --docs-dir docs \ --tagline "Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls" ``` -`--repo-url` is required in practice: pi-docsite otherwise derives it from the local -`origin`, so building from a fork rewrites every repository link and produces a diff -with no source changes. The tagline is the description shown in the sidebar and in -each page's metadata. +`--repo-url` matters: pi-docsite otherwise derives it from the local `origin`, so building from a +fork rewrites every repository link. The tagline is the description shown in the sidebar and in +each page's metadata; the workflow passes both for the same reason. -The output is deterministic: running it twice without source changes produces no diff. +The build is deterministic (two runs without source changes are byte-identical), but that is now +the workflow's property, not something a contributor has to prove. + +Links between pages use the built `.html` form, because that is what the published site +serves. Adding a page: create `docs/.md` and add `` to `docs/nav.json`. A `.md` missing from `nav.json` is still built, placed under a fallback group, and reported. Deleting a page: remove the `.md` **and** its slug from `docs/nav.json`. A slug with no -`.md` behind it is a build error, so the stale entry has to go first — the leftover -`.html` is then removed by the rebuild. +`.md` behind it is a build error, so the stale entry has to go first. + +Branches opened before the generated files were untracked will hit delete/modify conflicts on +exactly those paths. Resolve them by **deleting** (`git rm docs/foo.html docs/search-index.json …`) +and keeping the `.md` edit from the branch. **Never resolve by regenerating** — the regenerated +output reintroduces the conflict and is what the guard now rejects. ## Adding a new module (resource) diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docs/assets/callouts.js b/docs/assets/callouts.js deleted file mode 100644 index af82afc794..0000000000 --- a/docs/assets/callouts.js +++ /dev/null @@ -1,26 +0,0 @@ -(function() { - var blockquotes = document.querySelectorAll('blockquote'); - blockquotes.forEach(function(bq) { - var firstStrong = bq.querySelector('strong'); - if (!firstStrong) return; - - var text = firstStrong.textContent.toLowerCase().replace(':', '').trim(); - var type = null; - - if (text === 'note' || text === 'info') { - type = 'note'; - } else if (text === 'warning' || text === 'caution') { - type = 'warning'; - } else if (text === 'tip' || text === 'hint') { - type = 'tip'; - } else if (text === 'danger' || text === 'error') { - type = 'danger'; - } else if (text === 'important') { - type = 'important'; - } - - if (type) { - bq.classList.add('callout', 'callout-' + type); - } - }); -})(); diff --git a/docs/assets/codelabels.js b/docs/assets/codelabels.js deleted file mode 100644 index 34467b3035..0000000000 --- a/docs/assets/codelabels.js +++ /dev/null @@ -1,75 +0,0 @@ -(function() { - var blocks = document.querySelectorAll('pre code'); - blocks.forEach(function(code) { - var classes = code.className || ''; - var match = classes.match(/language-(\w+)/); - if (!match) return; - - var lang = match[1]; - var labelMap = { - 'python': 'Python', - 'py': 'Python', - 'javascript': 'JavaScript', - 'js': 'JavaScript', - 'typescript': 'TypeScript', - 'ts': 'TypeScript', - 'bash': 'Bash', - 'sh': 'Shell', - 'shell': 'Shell', - 'json': 'JSON', - 'yaml': 'YAML', - 'yml': 'YAML', - 'html': 'HTML', - 'css': 'CSS', - 'go': 'Go', - 'rust': 'Rust', - 'java': 'Java', - 'ruby': 'Ruby', - 'rb': 'Ruby', - 'sql': 'SQL', - 'dockerfile': 'Dockerfile', - 'docker': 'Dockerfile', - 'toml': 'TOML', - 'xml': 'XML', - 'c': 'C', - 'cpp': 'C++', - 'csharp': 'C#', - 'cs': 'C#', - 'php': 'PHP', - 'swift': 'Swift', - 'kotlin': 'Kotlin', - 'scala': 'Scala', - 'r': 'R', - 'lua': 'Lua', - 'perl': 'Perl', - 'makefile': 'Makefile', - 'graphql': 'GraphQL', - 'markdown': 'Markdown', - 'md': 'Markdown', - 'plaintext': 'Text', - 'text': 'Text', - 'ini': 'INI', - 'env': '.env', - }; - - var displayName = labelMap[lang.toLowerCase()] || lang; - - var pre = code.parentElement; - if (!pre || pre.tagName !== 'PRE') return; - - var label = document.createElement('span'); - label.className = 'code-label'; - label.textContent = displayName; - - var wrapper = pre.closest('.code-block-wrapper'); - if (wrapper) { - wrapper.insertBefore(label, wrapper.firstChild); - } else { - pre.style.position = 'relative'; - label.style.position = 'absolute'; - label.style.top = '0.5rem'; - label.style.right = '0.5rem'; - pre.insertBefore(label, pre.firstChild); - } - }); -})(); diff --git a/docs/assets/copy.js b/docs/assets/copy.js deleted file mode 100644 index 6339d08a1b..0000000000 --- a/docs/assets/copy.js +++ /dev/null @@ -1,77 +0,0 @@ -(function() { - // Icon-only so it does not sit over the code on a short block. The word moves - // to title/aria-label, which still gives a tooltip and a screen-reader name. - var ICON_COPY = - ''; - var ICON_OK = - ''; - var ICON_FAIL = - ''; - - function setIcon(btn, icon, label) { - btn.innerHTML = icon; - btn.title = label; - btn.setAttribute('aria-label', label); - } - - function flash(btn, icon, label, backTo, backLabel) { - setIcon(btn, icon, label); - btn.classList.add('copied'); - setTimeout(function() { - setIcon(btn, backTo, backLabel); - btn.classList.remove('copied'); - }, 2000); - } - - document.querySelectorAll('pre').forEach(function(pre) { - // Idempotent: this file can run more than once (cached asset, a second - // injection, a client-side navigation). Without this guard every run left - // another copy-btn behind and the block grew a stack of them. - var existing = pre.querySelector('.copy-btn'); - if (existing) existing.remove(); - var btn = document.createElement('button'); - btn.className = 'copy-btn'; - btn.type = 'button'; - setIcon(btn, ICON_COPY, 'Copy code'); - btn.addEventListener('click', function() { - var code = pre.querySelector('code'); - var text = code ? code.textContent : pre.textContent; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(text).then(function() { - flash(btn, ICON_OK, 'Copied', ICON_COPY, 'Copy code'); - }).catch(function() { - fallbackCopy(text, btn); - }); - } else { - fallbackCopy(text, btn); - } - }); - pre.style.position = 'relative'; - pre.appendChild(btn); - }); - - function fallbackCopy(text, btn) { - var textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - var ok = false; - try { - ok = document.execCommand('copy'); - } catch (e) { - ok = false; - } - document.body.removeChild(textarea); - if (ok) flash(btn, ICON_OK, 'Copied', ICON_COPY, 'Copy code'); - else flash(btn, ICON_FAIL, 'Copy failed', ICON_COPY, 'Copy code'); - } -})(); diff --git a/docs/assets/github.js b/docs/assets/github.js deleted file mode 100644 index bd57b2f0c4..0000000000 --- a/docs/assets/github.js +++ /dev/null @@ -1,38 +0,0 @@ -(function() { - var link = document.getElementById('github-link'); - if (!link) return; - - var repoUrl = link.getAttribute('data-repo-url'); - if (!repoUrl) return; - - // Extract owner/repo from GitHub URL - var match = repoUrl.match(/github\.com[/:]([^/]+)\/([^/.]+)/); - if (!match) return; - - var owner = match[1]; - var repo = match[2]; - - var starsEl = document.getElementById('github-stars'); - if (!starsEl) return; - - fetch('https://api.github.com/repos/' + owner + '/' + repo) - .then(function(response) { - if (!response.ok) return null; - return response.json(); - }) - .then(function(data) { - if (!data || typeof data.stargazers_count === 'undefined') return; - var count = data.stargazers_count; - var display; - if (count >= 1000) { - display = (count / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; - } else { - display = count.toString(); - } - starsEl.textContent = '★ ' + display; - starsEl.title = count.toLocaleString() + ' stars'; - }) - .catch(function() { - // Silently fail - star count is a nice-to-have - }); -})(); diff --git a/docs/assets/scrollspy.js b/docs/assets/scrollspy.js deleted file mode 100644 index 6ab088703d..0000000000 --- a/docs/assets/scrollspy.js +++ /dev/null @@ -1,49 +0,0 @@ -(function() { - var tocLinks = document.querySelectorAll('.toc-container a'); - if (tocLinks.length === 0) return; - - var headings = []; - tocLinks.forEach(function(link) { - var href = link.getAttribute('href'); - if (href && href.startsWith('#')) { - var target = document.getElementById(href.substring(1)); - if (target) { - headings.push({ element: target, link: link }); - } - } - }); - - if (headings.length === 0) return; - - function updateActive() { - var scrollPos = window.scrollY + 100; - var current = null; - - for (var i = 0; i < headings.length; i++) { - if (headings[i].element.offsetTop <= scrollPos) { - current = headings[i]; - } - } - - tocLinks.forEach(function(link) { - link.classList.remove('active'); - }); - - if (current) { - current.link.classList.add('active'); - } - } - - var ticking = false; - window.addEventListener('scroll', function() { - if (!ticking) { - window.requestAnimationFrame(function() { - updateActive(); - ticking = false; - }); - ticking = true; - } - }); - - updateActive(); -})(); diff --git a/docs/assets/search.js b/docs/assets/search.js deleted file mode 100644 index e7898d1073..0000000000 --- a/docs/assets/search.js +++ /dev/null @@ -1,186 +0,0 @@ -(function() { - // Create modal HTML - var overlay = document.createElement('div'); - overlay.className = 'search-modal-overlay'; - overlay.innerHTML = '
' + - '' + - '
' + - '' + - '
'; - document.body.appendChild(overlay); - - var input = overlay.querySelector('.search-modal-input'); - var results = overlay.querySelector('.search-modal-results'); - var selectedIdx = -1; - // Load index. Normally fetched once and shared by every page, so nothing is - // inlined and the pages stay small. An inline copy is used when the generator - // was asked to embed it. Note: fetch() of a local file is blocked by CORS on - // file:// in most browsers, so opening the site straight off disk needs the - // inline mode; served over HTTP this is not an issue. - var index = Array.isArray(window.__DOCS_SEARCH_INDEX__) ? window.__DOCS_SEARCH_INDEX__ : null; - var indexError = null; - // HTTP status when a response actually arrived, null when the request never - // completed (offline, DNS, CORS) or the body was not JSON. The two are not the - // same failure: a 500 or a dropped connection says nothing about the index - // file, so the reader must not be told to rebuild because of one. - var indexStatus = null; - // Opening the file straight off disk is the one case CORS blocks, and so the - // one case where "serve the site over HTTP" is the remedy. Over HTTP a failed - // fetch means a missing or corrupt search-index.json, and telling the reader - // to serve the site when they already do is advice that cannot help (#875). - var onFileProtocol = location.protocol === 'file:'; - if (!index) { - fetch('search-index.json').then(function(r) { - if (!r.ok) { indexStatus = r.status; throw new Error('HTTP ' + r.status); } - return r.json(); - }).then(function(searchIndex) { - index = searchIndex; - console.debug('[docs] search index loaded:', searchIndex.length, 'entries'); - // The user may have typed before the fetch resolved. Their first query - // was answered with "Loading search index..." and never re-run, so the - // results stayed hidden until they edited the box again. - if (typeof render === 'function' && input.value.trim()) render(); - }).catch(function(err) { - indexError = err; - console.error('[docs] search index unavailable:', err, - onFileProtocol - ? '— serve the site over HTTP, or build it with the search index inlined.' - : (indexStatus - ? '— the server answered HTTP ' + indexStatus + ' for search-index.json; the index may be fine.' - : '— search-index.json could not be fetched or parsed; check the network and rebuild if it persists.')); - // Same reason as the success path: a query typed while the request was - // pending would otherwise stay on "Loading search index..." forever, now - // that the request has failed. - if (typeof render === 'function' && input.value.trim()) render(); - }); - } - - // Open/close - function openModal() { - overlay.classList.add('active'); - input.value = ''; - results.innerHTML = ''; - selectedIdx = -1; - setTimeout(function() { input.focus(); }, 50); - } - - function closeModal() { - overlay.classList.remove('active'); - } - - // Keyboard shortcut - document.addEventListener('keydown', function(e) { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - openModal(); - } - if (e.key === 'Escape') closeModal(); - }); - - // Click overlay to close - overlay.addEventListener('click', function(e) { - if (e.target === overlay) closeModal(); - }); - - // Search trigger button in sidebar - var sidebarSearch = document.getElementById('search-input'); - if (sidebarSearch) { - sidebarSearch.addEventListener('focus', function(e) { - e.preventDefault(); - this.blur(); - openModal(); - }); - } - - // Search trigger button in top bar - var topBarSearch = document.getElementById('search-trigger'); - if (topBarSearch) { - topBarSearch.addEventListener('click', function(e) { - e.preventDefault(); - openModal(); - }); - } - - // Search logic - function render() { - var q = input.value.toLowerCase().trim(); - results.innerHTML = ''; - selectedIdx = -1; - if (!q) return; - - if (!index) { - var err = document.createElement('div'); - err.className = 'search-no-results'; - if (!indexError) { - err.textContent = 'Loading search index...'; - } else if (onFileProtocol) { - err.textContent = 'Search needs the site served over HTTP (opening the file directly blocks it).'; - } else if (indexStatus === 404) { - err.textContent = 'Search index not found — rebuild the site.'; - } else if (indexStatus === 401 || indexStatus === 403 || (indexStatus >= 400 && indexStatus < 500 && indexStatus !== 408 && indexStatus !== 429)) { - // A permanent client error: waiting does not lift an access restriction - // or a bad request, so do not send the reader round the retry loop. - err.textContent = 'Search index unavailable (HTTP ' + indexStatus + '). Check the site\'s access configuration.'; - } else if (indexStatus) { - err.textContent = 'Search index unavailable (HTTP ' + indexStatus + '). Try again shortly.'; - } else { - err.textContent = 'Search index could not be loaded (network error or invalid index).'; - } - results.appendChild(err); - return; - } - - var matches = index.filter(function(item) { - return item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q); - }).slice(0, 10); - - matches.forEach(function(m, i) { - var div = document.createElement('a'); - div.href = m.slug + '.html'; - div.className = 'search-result-item'; - - var title = document.createElement('span'); - title.className = 'search-result-title'; - title.textContent = m.title; - div.appendChild(title); - - // Content preview - var preview = document.createElement('span'); - preview.className = 'search-result-preview'; - var contentIdx = m.content.toLowerCase().indexOf(q); - if (contentIdx >= 0) { - var start = Math.max(0, contentIdx - 40); - var end = Math.min(m.content.length, contentIdx + q.length + 60); - var snippet = (start > 0 ? '...' : '') + m.content.substring(start, end) + (end < m.content.length ? '...' : ''); - preview.textContent = snippet; - } - div.appendChild(preview); - results.appendChild(div); - }); - - if (matches.length === 0) { - var empty = document.createElement('div'); - empty.className = 'search-no-results'; - empty.textContent = 'No results found'; - results.appendChild(empty); - } - } - - input.addEventListener('input', render); - - // Keyboard navigation - input.addEventListener('keydown', function(e) { - var items = results.querySelectorAll('.search-result-item'); - if (e.key === 'ArrowDown') { - e.preventDefault(); - selectedIdx = Math.min(selectedIdx + 1, items.length - 1); - items.forEach(function(item, i) { item.classList.toggle('selected', i === selectedIdx); }); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - selectedIdx = Math.max(selectedIdx - 1, 0); - items.forEach(function(item, i) { item.classList.toggle('selected', i === selectedIdx); }); - } else if (e.key === 'Enter' && selectedIdx >= 0 && items[selectedIdx]) { - window.location.href = items[selectedIdx].href; - } - }); -})(); diff --git a/docs/assets/style.css b/docs/assets/style.css deleted file mode 100644 index f87ad08ac4..0000000000 --- a/docs/assets/style.css +++ /dev/null @@ -1,1618 +0,0 @@ -/* ========================================================================== - pi-docsite - Documentation Theme (Warm Stone + Orange) - ========================================================================== */ - -/* -------------------------------------------------------------------------- - CSS Custom Properties (Light Theme - Default) - -------------------------------------------------------------------------- */ -:root { - /* Backgrounds */ - --bg-primary: #ffffff; - --bg-secondary: #fafaf9; - --bg-tertiary: #f5f5f4; - --bg-sidebar: #fafaf9; - --bg-code: #fef3c7; - --bg-code-block: #1c1917; - --bg-card: #ffffff; - --bg-search-result: #ffffff; - - /* Text */ - --text-primary: #1c1917; - --text-secondary: #57534e; - --text-tertiary: #78716c; - --text-inverse: #ffffff; - --text-link: #c2410c; - --text-link-hover: #9a3412; - --text-code: #92400e; - --text-code-block: #d4d4d8; - - /* Borders */ - --border-primary: #e7e5e4; - --border-secondary: #f5f5f4; - --border-focus: #ea580c; - - /* Accent */ - --accent: #c2410c; - --accent-light: #fff7ed; - --accent-hover: #9a3412; - --accent-contrast: #ffffff; - --accent-focus-ring: rgba(194, 65, 12, 0.15); - --accent-underline: rgba(194, 65, 12, 0.3); - - /* Callouts */ - --callout-note-bg: #eff6ff; - --callout-note-border: #3b82f6; - --callout-note-text: #1e40af; - --callout-warning-bg: #fffbeb; - --callout-warning-border: #f59e0b; - --callout-warning-text: #92400e; - --callout-tip-bg: #ecfdf5; - --callout-tip-border: #10b981; - --callout-tip-text: #065f46; - --callout-danger-bg: #fef2f2; - --callout-danger-border: #ef4444; - --callout-danger-text: #991b1b; - --callout-important-bg: #faf5ff; - --callout-important-border: #8b5cf6; - --callout-important-text: #5b21b6; - - /* Shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04); - - /* Layout */ - --sidebar-width: 280px; - --content-max-width: 720px; - --header-height: 56px; - - /* Typography */ - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; - --font-mono: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono", Menlo, Consolas, monospace; - - /* Transitions */ - --transition-fast: 150ms ease; - --transition-normal: 250ms ease; - - /* Table */ - --table-row-alt: #fafaf9; - --table-border: #e7e5e4; -} - -/* -------------------------------------------------------------------------- - Dark Theme - -------------------------------------------------------------------------- */ -[data-theme="dark"] { - --bg-primary: #1c1917; - --bg-secondary: #292524; - --bg-tertiary: #44403c; - --bg-sidebar: #292524; - --bg-code: rgba(251, 191, 36, 0.15); - /* Must differ from --bg-primary (#1c1917). It did not, so code blocks were - * invisible against the page in dark mode -- only the code text was legible - * and the block had no boundary at all. --bg-secondary is the palette's own - * next step up. */ - --bg-code-block: #292524; - --bg-card: #292524; - --bg-search-result: #292524; - - --text-primary: #e7e5e4; - --text-secondary: #a8a29e; - --text-tertiary: #78716c; - --text-inverse: #ffffff; - --text-link: #fb923c; - --text-link-hover: #fdba74; - --text-code: #fbbf24; - --text-code-block: #d4d4d8; - - --border-primary: #44403c; - --border-secondary: #292524; - --border-focus: #fb923c; - - --accent: #fb923c; - --accent-light: rgba(251, 146, 60, 0.1); - --accent-hover: #fdba74; - --accent-contrast: #1c1917; - --accent-focus-ring: rgba(251, 146, 60, 0.15); - --accent-underline: rgba(251, 146, 60, 0.3); - - --callout-note-bg: #172554; - --callout-note-border: #3b82f6; - --callout-note-text: #93c5fd; - --callout-warning-bg: #451a03; - --callout-warning-border: #f59e0b; - --callout-warning-text: #fcd34d; - --callout-tip-bg: #052e16; - --callout-tip-border: #10b981; - --callout-tip-text: #6ee7b7; - --callout-danger-bg: #450a0a; - --callout-danger-border: #ef4444; - --callout-danger-text: #fca5a5; - --callout-important-bg: #2e1065; - --callout-important-border: #8b5cf6; - --callout-important-text: #c4b5fd; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3); - - --table-row-alt: #292524; - --table-border: #44403c; -} - -/* -------------------------------------------------------------------------- - Reset & Base - -------------------------------------------------------------------------- */ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - font-size: 16px; - scroll-behavior: smooth; - scroll-padding-top: calc(var(--header-height, 60px) + 20px); - -webkit-text-size-adjust: 100%; -} - -body { - font-family: var(--font-sans); - color: var(--text-primary); - background-color: var(--bg-primary); - line-height: 1.7; - transition: background-color var(--transition-normal), color var(--transition-normal); - display: flex; - min-height: 100vh; -} - -a { - color: var(--text-link); - text-decoration: none; - transition: color var(--transition-fast); -} - -a:hover { - color: var(--text-link-hover); - text-decoration: underline; -} - -/* -------------------------------------------------------------------------- - Sidebar - -------------------------------------------------------------------------- */ -.sidebar { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: var(--sidebar-width); - background: var(--bg-sidebar); - border-right: 1px solid var(--border-primary); - overflow-y: auto; - z-index: 100; - display: flex; - flex-direction: column; - transition: background-color var(--transition-normal), border-color var(--transition-normal); -} - -.sidebar-header { - padding: 24px 20px 16px; - border-bottom: 1px solid var(--border-secondary); -} - -.sidebar-logo { - display: block; - font-size: 1.125rem; - font-weight: 700; - color: var(--text-primary); - text-decoration: none; - letter-spacing: -0.01em; -} - -.sidebar-logo:hover { - color: var(--accent); - text-decoration: none; -} - -.sidebar-tagline { - font-size: 0.8125rem; - color: var(--text-tertiary); - margin-top: 4px; - line-height: 1.4; -} - -/* Sidebar Search */ -.sidebar-search { - padding: 12px 20px; - position: relative; -} - -#search-input { - width: 100%; - padding: 8px 12px; - font-size: 0.875rem; - font-family: var(--font-sans); - border: 1px solid var(--border-primary); - border-radius: 8px; - background: var(--bg-primary); - color: var(--text-primary); - outline: none; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast), - background-color var(--transition-normal); -} - -#search-input::placeholder { - color: var(--text-tertiary); -} - -#search-input:focus { - border-color: var(--border-focus); - box-shadow: 0 0 0 3px var(--accent-focus-ring); -} - -.search-results { - display: none; - position: absolute; - top: 100%; - left: 20px; - right: 20px; - background: var(--bg-search-result); - border: 1px solid var(--border-primary); - border-radius: 8px; - box-shadow: var(--shadow-lg); - z-index: 200; - max-height: 300px; - overflow-y: auto; -} - -.search-result-item { - display: block; - padding: 10px 14px; - font-size: 0.875rem; - color: var(--text-primary); - border-bottom: 1px solid var(--border-secondary); - text-decoration: none; - transition: background-color var(--transition-fast); -} - -.search-result-item:last-child { - border-bottom: none; -} - -.search-result-item:hover { - background: var(--accent-light); - color: var(--accent); - text-decoration: none; -} - -/* Sidebar Navigation */ -.sidebar-nav { - flex: 1; - padding: 8px 0 24px; - overflow-y: auto; -} - -.nav-group { - padding: 0 12px; - margin-bottom: 8px; -} - -.nav-group-title { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-tertiary); - padding: 12px 8px 6px; -} - -.nav-group-pages { - list-style: none; -} - -.nav-link { - display: block; - padding: 6px 12px; - font-size: 0.875rem; - color: var(--text-secondary); - border-radius: 6px; - text-decoration: none; - transition: color var(--transition-fast), background-color var(--transition-fast); - line-height: 1.5; -} - -.nav-link:hover { - color: var(--text-primary); - background: var(--bg-tertiary); - text-decoration: none; -} - -.nav-link.active { - color: var(--accent); - background: var(--accent-light); - font-weight: 500; -} - -/* -------------------------------------------------------------------------- - Main Wrapper - -------------------------------------------------------------------------- */ -.main-wrapper { - margin-left: var(--sidebar-width); - flex: 1; - display: flex; - flex-direction: column; - min-height: 100vh; - /* Flex items default to min-width:auto, so a wide table inside made this grow - * past the viewport and put a scrollbar on the whole document. 0 lets it - * shrink so the table scrolls in its own box instead. */ - min-width: 0; - transition: margin-left var(--transition-normal); -} - -/* -------------------------------------------------------------------------- - Top Bar - -------------------------------------------------------------------------- */ -.top-bar { - position: sticky; - top: 0; - height: var(--header-height); - display: flex; - align-items: center; - padding: 0 24px; - background: var(--bg-primary); - border-bottom: 1px solid var(--border-primary); - z-index: 50; - transition: background-color var(--transition-normal), border-color var(--transition-normal); -} - -.top-bar-spacer { - flex: 1; -} - -.sidebar-toggle { - display: none; - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 6px; - border-radius: 6px; - transition: color var(--transition-fast), background-color var(--transition-fast); -} - -.sidebar-toggle:hover { - color: var(--text-primary); - background: var(--bg-tertiary); -} - -.theme-toggle { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 6px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - transition: color var(--transition-fast), background-color var(--transition-fast); -} - -.theme-toggle:hover { - color: var(--text-primary); - background: var(--bg-tertiary); -} - -/* Show sun in dark mode, moon in light mode */ -.icon-sun { - display: none; -} - -.icon-moon { - display: block; -} - -[data-theme="dark"] .icon-sun { - display: block; -} - -[data-theme="dark"] .icon-moon { - display: none; -} - -/* -------------------------------------------------------------------------- - Content - -------------------------------------------------------------------------- */ -.content { - flex: 1; - padding: calc(var(--header-height, 60px) + 20px) 48px 40px; - max-width: calc(var(--content-max-width) + 96px); -} - -.article { - max-width: var(--content-max-width); -} - -.article-title { - font-size: 2rem; - font-weight: 700; - letter-spacing: -0.025em; - color: var(--text-primary); - margin-bottom: 24px; - line-height: 1.25; -} - -.article-body { - font-size: 1rem; - line-height: 1.8; - color: var(--text-secondary); -} - -/* Typography in article body */ -.article-body h1 { - font-size: 1.875rem; - font-weight: 700; - margin-top: 56px; - margin-bottom: 20px; - color: var(--text-primary); - letter-spacing: -0.02em; - line-height: 1.3; -} - -.article-body h2 { - font-size: 1.5rem; - font-weight: 600; - margin-top: 48px; - margin-bottom: 16px; - color: var(--text-primary); - letter-spacing: -0.015em; - line-height: 1.35; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-secondary); -} - -.article-body h3 { - font-size: 1.25rem; - font-weight: 600; - margin-top: 40px; - margin-bottom: 12px; - color: var(--text-primary); - line-height: 1.4; -} - -.article-body h4 { - font-size: 1.0625rem; - font-weight: 600; - margin-top: 32px; - margin-bottom: 10px; - color: var(--text-primary); -} - -.article-body p { - margin-bottom: 18px; -} - -.article-body ul, -.article-body ol { - margin-bottom: 18px; - padding-left: 24px; -} - -.article-body li { - margin-bottom: 8px; -} - -.article-body li > ul, -.article-body li > ol { - margin-top: 6px; - margin-bottom: 0; -} - -/* Inline code */ -.article-body code { - font-family: var(--font-mono); - font-size: 0.875em; - background: var(--bg-code); - color: var(--text-code); - padding: 2px 6px; - border-radius: 4px; -} - -/* Code blocks (Pygments / codehilite) */ -.article-body pre { - margin-bottom: 24px; - border-radius: 8px; - overflow-x: auto; - font-size: 0.875rem; - line-height: 1.6; - /* Wrapping rather than scrolling: a clipped command is unreadable, and the - * horizontal scrollbar it produced also collided with the copy button in - * the block's top-right corner. Only 3 lines across docs/ exceed 110 chars - * and none of them are alignment-critical. */ - white-space: pre-wrap; - overflow-wrap: break-word; - /* Reserve room for the absolutely-positioned copy button even when the - * block is a single short line. A one-line block is only ~22px tall while - * the button plus its top offset needs ~28px, and the block is an overflow - * container, so without this it grew a scrollbar. */ - min-height: 34px; - /* Anchors the .copy-btn and .code-label, which are appended as children by - * copy.js and codelabels.js and positioned with position:absolute. Without - * this they resolve against the page instead of the code box, so the copy - * button and language label drift to wherever the block happens to sit. */ - position: relative; -} - -.article-body .table-wrap { - /* Wide tables scroll inside their own box instead of widening the page. */ - overflow-x: auto; - margin-bottom: 24px; - -webkit-overflow-scrolling: touch; -} - -.article-body .table-wrap > table { - margin-bottom: 0; -} - -.article-body .highlight { - background: var(--bg-code-block); - border-radius: 8px; - padding: 16px 20px; - overflow-x: auto; - margin-bottom: 24px; - /* Same reason as .article-body pre: this wrapper is what a .code-label is - * attached to when the block has one. */ - position: relative; -} - -.article-body .highlight pre { - margin: 0; - white-space: pre-wrap; - overflow-wrap: break-word; - background: transparent; - padding: 0; - color: var(--text-code-block); -} - -.article-body .highlight code { - background: transparent; - color: inherit; - padding: 0; - font-size: 0.875rem; -} - -/* Pygments syntax highlighting tokens */ -.highlight .hll { background-color: #3d405b; } -.highlight .c { color: #6a737d; font-style: italic; } /* Comment */ -.highlight .k { color: #c678dd; font-weight: bold; } /* Keyword */ -.highlight .o { color: #c678dd; } /* Operator */ -.highlight .cm { color: #6a737d; font-style: italic; } /* Comment.Multiline */ -.highlight .cp { color: #6a737d; font-weight: bold; } /* Comment.Preproc */ -.highlight .c1 { color: #6a737d; font-style: italic; } /* Comment.Single */ -.highlight .cs { color: #6a737d; font-style: italic; } /* Comment.Special */ -.highlight .gd { color: #e06c75; } /* Generic.Deleted */ -.highlight .gi { color: #98c379; } /* Generic.Inserted */ -.highlight .ge { font-style: italic; } /* Generic.Emph */ -.highlight .gs { font-weight: bold; } /* Generic.Strong */ -.highlight .gu { color: #56b6c2; font-weight: bold; } /* Generic.Subheading */ -.highlight .kc { color: #c678dd; font-weight: bold; } /* Keyword.Constant */ -.highlight .kd { color: #c678dd; font-weight: bold; } /* Keyword.Declaration */ -.highlight .kn { color: #c678dd; } /* Keyword.Namespace */ -.highlight .kp { color: #c678dd; } /* Keyword.Pseudo */ -.highlight .kr { color: #c678dd; font-weight: bold; } /* Keyword.Reserved */ -.highlight .kt { color: #e5c07b; } /* Keyword.Type */ -.highlight .m { color: #d19a66; } /* Literal.Number */ -.highlight .s { color: #98c379; } /* Literal.String */ -.highlight .na { color: #e06c75; } /* Name.Attribute */ -.highlight .nb { color: #56b6c2; } /* Name.Builtin */ -.highlight .nc { color: #e5c07b; font-weight: bold; } /* Name.Class */ -.highlight .no { color: #e06c75; } /* Name.Constant */ -.highlight .nd { color: #61afef; } /* Name.Decorator */ -.highlight .nf { color: #61afef; } /* Name.Function */ -.highlight .nn { color: #e5c07b; } /* Name.Namespace */ -.highlight .nt { color: #e06c75; } /* Name.Tag */ -.highlight .nv { color: #e06c75; } /* Name.Variable */ -.highlight .ow { color: #c678dd; font-weight: bold; } /* Operator.Word */ -.highlight .w { color: #abb2bf; } /* Text.Whitespace */ -.highlight .mb { color: #d19a66; } /* Literal.Number.Bin */ -.highlight .mf { color: #d19a66; } /* Literal.Number.Float */ -.highlight .mh { color: #d19a66; } /* Literal.Number.Hex */ -.highlight .mi { color: #d19a66; } /* Literal.Number.Integer */ -.highlight .mo { color: #d19a66; } /* Literal.Number.Oct */ -.highlight .sa { color: #98c379; } /* Literal.String.Affix */ -.highlight .sb { color: #98c379; } /* Literal.String.Backtick */ -.highlight .sc { color: #98c379; } /* Literal.String.Char */ -.highlight .dl { color: #98c379; } /* Literal.String.Delimiter */ -.highlight .sd { color: #98c379; font-style: italic; } /* Literal.String.Doc */ -.highlight .s2 { color: #98c379; } /* Literal.String.Double */ -.highlight .se { color: #d19a66; } /* Literal.String.Escape */ -.highlight .sh { color: #98c379; } /* Literal.String.Heredoc */ -.highlight .si { color: #98c379; } /* Literal.String.Interpol */ -.highlight .sx { color: #98c379; } /* Literal.String.Other */ -.highlight .sr { color: #56b6c2; } /* Literal.String.Regex */ -.highlight .s1 { color: #98c379; } /* Literal.String.Single */ -.highlight .ss { color: #98c379; } /* Literal.String.Symbol */ -.highlight .bp { color: #56b6c2; } /* Name.Builtin.Pseudo */ -.highlight .fm { color: #61afef; } /* Name.Function.Magic */ -.highlight .vc { color: #e06c75; } /* Name.Variable.Class */ -.highlight .vg { color: #e06c75; } /* Name.Variable.Global */ -.highlight .vi { color: #e06c75; } /* Name.Variable.Instance */ -.highlight .vm { color: #e06c75; } /* Name.Variable.Magic */ -.highlight .il { color: #d19a66; } /* Literal.Number.Integer.Long */ - -/* -------------------------------------------------------------------------- - Blockquotes / Callouts - -------------------------------------------------------------------------- */ -.article-body blockquote p { - margin-bottom: 8px; -} - -.article-body blockquote p:last-child { - margin-bottom: 0; -} - -/* Callout boxes */ -blockquote.callout-note, -blockquote.callout-warning, -blockquote.callout-tip, -blockquote.callout-danger, -blockquote.callout-important { - border-left-width: 4px; - border-left-style: solid; - padding: 1rem 1.25rem; - border-radius: 0 8px 8px 0; - margin: 1.5rem 0; -} - -blockquote.callout-note { - border-left-color: var(--callout-note-border); - background: var(--callout-note-bg); - color: var(--callout-note-text); -} - -blockquote.callout-warning { - border-left-color: var(--callout-warning-border); - background: var(--callout-warning-bg); - color: var(--callout-warning-text); -} - -blockquote.callout-tip { - border-left-color: var(--callout-tip-border); - background: var(--callout-tip-bg); - color: var(--callout-tip-text); -} - -blockquote.callout-danger { - border-left-color: var(--callout-danger-border); - background: var(--callout-danger-bg); - color: var(--callout-danger-text); -} - -blockquote.callout-important { - border-left-color: var(--callout-important-border); - background: var(--callout-important-bg); - color: var(--callout-important-text); -} - -/* Chained :not() for broader browser compatibility */ -/* Default blockquote styling (not a callout) */ -blockquote:not(.callout-note):not(.callout-warning):not(.callout-tip):not(.callout-danger):not(.callout-important) { - border-left: 4px solid var(--border-primary); - padding: 1rem 1.25rem; - margin: 1.5rem 0; - color: var(--text-secondary); -} - -/* -------------------------------------------------------------------------- - Tables - -------------------------------------------------------------------------- */ -.article-body table { - width: 100%; - border-collapse: collapse; - margin-bottom: 24px; - font-size: 0.9375rem; - border: 1px solid var(--table-border); - border-radius: 8px; - overflow: hidden; -} - -.article-body thead { - background: var(--bg-tertiary); -} - -.article-body th { - padding: 10px 16px; - text-align: left; - font-weight: 600; - color: var(--text-primary); - border-bottom: 2px solid var(--table-border); - font-size: 0.8125rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.article-body td { - padding: 10px 16px; - border-bottom: 1px solid var(--table-border); - color: var(--text-secondary); -} - -.article-body tbody tr:nth-child(even) { - background: var(--table-row-alt); -} - -.article-body tbody tr:hover { - background: var(--accent-light); -} - -/* -------------------------------------------------------------------------- - Links in article - -------------------------------------------------------------------------- */ -.article-body a { - color: var(--text-link); - text-decoration: underline; - text-decoration-color: var(--accent-underline); - text-underline-offset: 2px; - transition: color var(--transition-fast), text-decoration-color var(--transition-fast); -} - -.article-body a:hover { - color: var(--text-link-hover); - text-decoration-color: var(--text-link-hover); -} - -/* -------------------------------------------------------------------------- - Images - -------------------------------------------------------------------------- */ -.article-body img { - max-width: 100%; - height: auto; - border-radius: 8px; - margin: 16px 0; -} - -/* -------------------------------------------------------------------------- - Horizontal Rule - -------------------------------------------------------------------------- */ -.article-body hr { - border: none; - border-top: 1px solid var(--border-primary); - margin: 40px 0; -} - -/* -------------------------------------------------------------------------- - Details / Summary (Collapsible Sections) - -------------------------------------------------------------------------- */ -details { - margin: 1.5rem 0; - border: 1px solid var(--border-primary); - border-radius: 8px; - overflow: hidden; -} - -details summary { - padding: 12px 16px; - font-weight: 600; - cursor: pointer; - background: var(--bg-secondary); - color: var(--text-primary); - list-style: none; - display: flex; - align-items: center; - gap: 8px; -} - -details summary::before { - content: '▶'; - font-size: 0.75rem; - transition: transform 0.2s ease; - color: var(--text-tertiary); -} - -details[open] summary::before { - transform: rotate(90deg); -} - -details summary::-webkit-details-marker { - display: none; -} - -details > *:not(summary) { - padding: 0 16px; -} - -details > *:not(summary):first-of-type { - padding-top: 16px; -} - -details > p:last-child, -details > ul:last-child, -details > ol:last-child { - padding-bottom: 16px; -} - -/* -------------------------------------------------------------------------- - Index Header (Landing Page) - -------------------------------------------------------------------------- */ -.index-header { - padding: 48px 0 40px; - border-bottom: 1px solid var(--border-primary); - margin-bottom: 40px; -} - -.index-title { - font-size: 2.25rem; - font-weight: 800; - letter-spacing: -0.03em; - color: var(--text-primary); - margin-bottom: 8px; - line-height: 1.2; -} - -.index-tagline { - font-size: 1.125rem; - color: var(--text-secondary); - margin-bottom: 24px; - line-height: 1.5; - max-width: 560px; -} - -.index-cta { - display: inline-block; - padding: 10px 24px; - background: var(--accent); - color: var(--accent-contrast); - border-radius: 6px; - font-size: 0.9375rem; - font-weight: 600; - text-decoration: none; - transition: background var(--transition-fast); -} - -.index-cta:hover { - background: var(--accent-hover); - color: var(--accent-contrast); - text-decoration: none; -} - -/* -------------------------------------------------------------------------- - Card Grid (Index Page) - -------------------------------------------------------------------------- */ -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - margin-bottom: 48px; -} - -.card { - background: var(--bg-card); - border: 1px solid var(--border-primary); - border-radius: 12px; - padding: 24px; - align-self: start; - transition: border-color var(--transition-fast), - background-color var(--transition-normal); -} - -.card:hover { - border-color: var(--accent); -} - -.card-title { - font-size: 1.0625rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 12px; -} - -.card-pages { - list-style: none; - margin-bottom: 16px; -} - -.card-pages li { - margin-bottom: 8px; -} - -.card-pages a { - font-size: 0.9375rem; - color: var(--text-link); - text-decoration: none; -} - -.card-pages a:hover { - text-decoration: underline; -} - -.card-page-desc { - font-size: 0.8125rem; - color: var(--text-tertiary); - margin-top: 2px; - line-height: 1.4; -} - -.card-link { - display: inline-block; - font-size: 0.875rem; - font-weight: 500; - color: var(--accent); - text-decoration: none; - transition: color var(--transition-fast); -} - -.card-link:hover { - color: var(--accent-hover); - text-decoration: none; -} - -/* -------------------------------------------------------------------------- - Table of Contents - Right Sidebar - -------------------------------------------------------------------------- */ -.toc-sidebar { - position: fixed; - top: var(--header-height); - right: 20px; - width: 200px; - max-height: calc(100vh - var(--header-height) - 20px); - overflow-y: auto; - font-size: 0.8125rem; - display: none; -} - -.toc-container h3 { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); - margin-bottom: 0.75rem; - position: sticky; - top: 0; - background: var(--bg-primary); - padding: 12px 0 8px; - z-index: 1; -} - -.toc-container ul { - list-style: none; - padding: 0; - margin: 0; -} - -.toc-container li { - margin-bottom: 0.4rem; -} - -.toc-container a { - color: var(--text-secondary); - text-decoration: none; - display: block; - padding: 0.2rem 0; - padding-left: 0.75rem; - border-left: 2px solid var(--border-primary); - transition: all 0.15s ease; -} - -.toc-container a:hover { - color: var(--accent); - border-left-color: var(--accent); -} - -/* Hide h3+ nested items to limit TOC depth to h2 only */ -.toc-container ul ul { - display: none; -} - -/* Show TOC and adjust main content on wide screens only */ -@media (min-width: 1280px) { - .toc-sidebar { - display: block; - } - - .content { - margin-right: 220px; - } -} - -/* TOC is hidden by default (display: none above), no extra rule needed */ - -/* -------------------------------------------------------------------------- - Footer - -------------------------------------------------------------------------- */ -.footer { - padding: 24px 48px; - border-top: 1px solid var(--border-primary); - color: var(--text-tertiary); - font-size: 0.8125rem; - transition: border-color var(--transition-normal); -} - -.footer a { - color: var(--text-link); - text-decoration: none; -} - -.footer a:hover { - text-decoration: underline; -} - -/* -------------------------------------------------------------------------- - Sidebar Overlay (Mobile) - -------------------------------------------------------------------------- */ -.sidebar-overlay { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 90; -} - -.sidebar-overlay.open { - display: block; -} - -/* -------------------------------------------------------------------------- - Responsive Design - -------------------------------------------------------------------------- */ -@media (max-width: 768px) { - .sidebar { - transform: translateX(-100%); - transition: transform var(--transition-normal); - } - - .sidebar.open { - transform: translateX(0); - } - - .main-wrapper { - margin-left: 0; - } - - .sidebar-toggle { - display: flex; - } - - .content { - padding: 24px 20px; - } - - .footer { - padding: 20px; - } - - .index-header { - padding: 32px 0 28px; - } - - .index-title { - font-size: 1.75rem; - } - - .index-tagline { - font-size: 1rem; - } - - .article-title { - font-size: 1.625rem; - } - - .card-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 480px) { - /* Below this the top bar cannot fit the brand and the search/badge cluster - * without crowding. The sidebar carries the same brand, and the hamburger - * opens it, so hiding the duplicate is cleaner than shrinking it. */ - .top-bar-title { - display: none; - } - .index-title { - font-size: 1.5rem; - } - - .article-body h1 { - font-size: 1.5rem; - } - - .article-body h2 { - font-size: 1.25rem; - } - - .article-body h3 { - font-size: 1.125rem; - } -} - -/* -------------------------------------------------------------------------- - Scrollbar Styling - -------------------------------------------------------------------------- */ -.sidebar::-webkit-scrollbar { - width: 4px; -} - -.sidebar::-webkit-scrollbar-track { - background: transparent; -} - -.sidebar::-webkit-scrollbar-thumb { - background: var(--border-primary); - border-radius: 4px; -} - -.sidebar::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} - -/* -------------------------------------------------------------------------- - Print Styles - -------------------------------------------------------------------------- */ -@media print { - .sidebar, - .top-bar, - .sidebar-overlay { - display: none !important; - } - - .main-wrapper { - margin-left: 0 !important; - } - - .content { - padding: 0; - max-width: 100%; - } -} - -/* -------------------------------------------------------------------------- - Code Copy Button - -------------------------------------------------------------------------- */ -.copy-btn { - position: absolute; - top: 8px; - right: 8px; - /* Above the code text and the block's own scrollbar, which both sit in the - * same corner region on a short block. */ - z-index: 2; - /* Must stay shorter than one line of code (~22px at this font-size). The - * block is an overflow container, so a taller button makes a single-line - * block scroll vertically and produces a scrollbar next to the code. */ - padding: 3px; - width: 20px; - height: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 0; - font-size: 0.75rem; - background: var(--bg-secondary); - color: var(--text-secondary); - border: 1px solid var(--border-primary); - border-radius: 4px; - cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease; -} - -pre:hover .copy-btn { - opacity: 1; -} - -/* Make copy button always visible on touch devices */ -@media (hover: none) { - .copy-btn { - opacity: 0.7; - } -} - -.copy-btn:hover { - background: var(--accent); - color: var(--accent-contrast); - border-color: var(--accent); -} - -/* Success/failure state: the icon changes, so colour alone carries the result. */ -.copy-btn.copied { - background: var(--accent); - color: var(--accent-contrast); - border-color: var(--accent); -} - -/* -------------------------------------------------------------------------- - Prev/Next Page Navigation - -------------------------------------------------------------------------- */ -.page-nav { - display: flex; - justify-content: space-between; - gap: 1rem; - margin-top: 3rem; - padding-top: 2rem; - border-top: 1px solid var(--border-primary); -} - -.page-nav-link { - display: flex; - flex-direction: column; - padding: 1rem 1.25rem; - border: 1px solid var(--border-primary); - border-radius: 8px; - text-decoration: none; - transition: all 0.15s ease; - max-width: 50%; -} - -.page-nav-link:hover { - border-color: var(--accent); - text-decoration: none; -} - -.page-nav-next { - text-align: right; - margin-left: auto; -} - -.page-nav-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); - margin-bottom: 0.25rem; -} - -.page-nav-title { - font-size: 0.95rem; - font-weight: 600; - color: var(--accent); -} - -/* -------------------------------------------------------------------------- - Search Modal (Cmd+K) - -------------------------------------------------------------------------- */ -.search-modal-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 1000; - justify-content: center; - padding-top: 15vh; -} - -.search-modal-overlay.active { - display: flex; -} - -.search-modal { - background: var(--bg-primary); - border: 1px solid var(--border-primary); - border-radius: 12px; - width: 560px; - max-height: 480px; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); - overflow: hidden; -} - -.search-modal-input { - width: 100%; - padding: 16px 20px; - border: none; - border-bottom: 1px solid var(--border-primary); - background: transparent; - color: var(--text-primary); - font-size: 1rem; - outline: none; - box-sizing: border-box; -} - -.search-modal-results { - overflow-y: auto; - flex: 1; -} - -.search-modal .search-result-item { - display: flex; - flex-direction: column; - padding: 12px 20px; - text-decoration: none; - border-bottom: 1px solid var(--border-primary); - transition: background 0.1s ease; -} - -.search-modal .search-result-item:hover, -.search-modal .search-result-item.selected { - background: var(--bg-secondary); -} - -.search-result-title { - font-weight: 600; - color: var(--text-primary); - font-size: 0.9rem; -} - -.search-result-preview { - font-size: 0.8rem; - color: var(--text-secondary); - margin-top: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.search-no-results { - padding: 20px; - text-align: center; - color: var(--text-secondary); -} - -.search-modal-footer { - padding: 8px 20px; - font-size: 0.75rem; - color: var(--text-secondary); - border-top: 1px solid var(--border-primary); - text-align: center; -} - -.search-modal-footer kbd { - display: inline-block; - padding: 2px 6px; - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: 4px; - font-family: inherit; - font-size: 0.7rem; -} - -/* -------------------------------------------------------------------------- - Scroll Spy - TOC Active State - -------------------------------------------------------------------------- */ -.toc-container a.toc-active { - color: var(--accent); - border-left-color: var(--accent); - font-weight: 500; -} - -/* -------------------------------------------------------------------------- - Top Bar - Desktop Visibility & Layout - -------------------------------------------------------------------------- */ -.top-bar-left { - display: flex; - align-items: center; - gap: 8px; -} - -.top-bar-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - text-decoration: none; - letter-spacing: -0.01em; - /* Never break the project name across lines. It sat in a cramped flex row - * next to the search field and badge, and wrapped to two lines at narrow - * widths, which reads as a layout bug rather than a design. */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.top-bar-title:hover { - color: var(--accent); - text-decoration: none; -} - -.top-bar-right { - display: flex; - align-items: center; - gap: 8px; -} - -.top-bar-search-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: 8px; - color: var(--text-tertiary); - font-size: 0.8125rem; - cursor: pointer; - transition: color var(--transition-fast), background-color var(--transition-fast), - border-color var(--transition-fast); -} - -.top-bar-search-btn:hover { - color: var(--text-primary); - background: var(--bg-tertiary); - border-color: var(--border-primary); -} - -.top-bar-search-btn kbd { - display: inline-block; - padding: 1px 5px; - background: var(--bg-primary); - border: 1px solid var(--border-primary); - border-radius: 4px; - font-family: inherit; - font-size: 0.7rem; - color: var(--text-tertiary); -} - -.top-bar-github { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - border: 1px solid var(--border-primary); - border-radius: 6px; - color: var(--text-secondary); - text-decoration: none; - font-size: 0.85rem; - transition: color var(--transition-fast), background-color var(--transition-fast), - border-color var(--transition-fast); -} - -.top-bar-github:hover { - color: var(--text-primary); - background: var(--bg-tertiary); - border-color: var(--accent); - text-decoration: none; -} - -.top-bar-github svg { - flex-shrink: 0; -} - -.github-stars { - font-weight: 600; - font-size: 0.8rem; -} - -.github-stars:empty { - display: none; -} - -/* -------------------------------------------------------------------------- - Code Language Labels - -------------------------------------------------------------------------- */ -.code-label { - position: absolute; - top: 8px; - right: 60px; - z-index: 2; - font-size: 0.7rem; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.6; -} - -/* -------------------------------------------------------------------------- - Generator Branding Badge - -------------------------------------------------------------------------- */ -.docsite-badge { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - font-size: 0.75rem; - font-weight: 600; - color: var(--accent); - text-decoration: none; - border: 1px solid var(--border-primary); - border-radius: 4px; - transition: all 0.15s ease; - white-space: nowrap; -} - -.docsite-badge:hover { - border-color: var(--accent); - background: var(--accent-light); -} - -.brand-accent { - color: var(--accent); -} - -/* -------------------------------------------------------------------------- - Page Footer - -------------------------------------------------------------------------- */ -.page-footer { - margin-top: 3rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-primary); - text-align: center; - font-size: 0.8rem; - color: var(--text-tertiary); -} - -.page-footer a { - color: var(--accent); - text-decoration: none; - font-weight: 600; -} - -.page-footer a:hover { - text-decoration: underline; -} - -/* -------------------------------------------------------------------------- - LLM Docs Banner - -------------------------------------------------------------------------- */ -.llm-docs-banner { - display: flex; - align-items: flex-start; - gap: 1rem; - padding: 1.25rem 1.5rem; - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: 8px; - margin-bottom: 2rem; -} - -.llm-docs-icon { - font-size: 1.5rem; - line-height: 1; -} - -.llm-docs-content { - flex: 1; -} - -.llm-docs-content strong { - display: block; - margin-bottom: 0.25rem; - color: var(--text-primary); -} - -.llm-docs-content p { - margin: 0 0 0.5rem; - font-size: 0.85rem; - color: var(--text-secondary); -} - -.llm-docs-links { - font-size: 0.85rem; -} - -.llm-docs-links a { - color: var(--accent); - text-decoration: none; - font-weight: 500; -} - -.llm-docs-links a:hover { - text-decoration: underline; -} - -.llm-docs-sep { - margin: 0 0.5rem; - color: var(--text-tertiary); -} - -.footer-sep { - margin: 0 0.5rem; - color: var(--text-tertiary); -} - -.footer-llm { - font-size: 0.8rem; -} - -.footer-llm a { - color: var(--accent); - text-decoration: none; -} - -.footer-llm a:hover { - text-decoration: underline; -} diff --git a/docs/assets/theme.js b/docs/assets/theme.js deleted file mode 100644 index 9d51df31bc..0000000000 --- a/docs/assets/theme.js +++ /dev/null @@ -1,22 +0,0 @@ -(function() { - function getTheme() { - try { return localStorage.getItem('theme'); } catch(e) { return null; } - } - function setTheme(theme) { - try { localStorage.setItem('theme', theme); } catch(e) {} - } - - var toggle = document.getElementById('theme-toggle'); - var stored = getTheme(); - if (stored === 'dark' || stored === 'light') { - document.documentElement.setAttribute('data-theme', stored); - } else { - document.documentElement.setAttribute('data-theme', 'light'); - } - if (toggle) toggle.addEventListener('click', function() { - var current = document.documentElement.getAttribute('data-theme'); - var next = current === 'dark' ? 'light' : 'dark'; - document.documentElement.setAttribute('data-theme', next); - setTheme(next); - }); -})(); diff --git a/docs/class-generator-cli.html b/docs/class-generator-cli.html deleted file mode 100644 index 4449504b67..0000000000 --- a/docs/class-generator-cli.html +++ /dev/null @@ -1,1643 +0,0 @@ - - - - - - class-generator CLI Reference - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

class-generator CLI Reference

-

The class-generator command generates Python wrapper classes for Kubernetes and OpenShift resources. It is installed as a console script entry point from the openshift-python-wrapper package.

-
class-generator = "class_generator.cli:main"
-
- -
pip install openshift-python-wrapper
-# or
-uv tool install openshift-python-wrapper
-
- -
-

Tip: Enable shell completion by adding to ~/.bashrc or ~/.zshrc: -bash -if type class-generator > /dev/null; then eval "$(_CLASS_GENERATOR_COMPLETE=zsh_source class-generator)"; fi

-
-

Synopsis

-
class-generator [OPTIONS]
-
- -

At least one action option must be specified: --kind, --update-schema, --update-schema-for, --discover-missing, --coverage-report, --generate-missing, or --regenerate-all.

-
-

Options Reference

-

Kind Generation

-

-k, --kind

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypeSTRING
DefaultNone
RequiredNo (but at least one action must be specified)
RequiresConnected cluster with admin privileges
-

Generate Python wrapper classes for the specified Kubernetes resource Kind(s). Multiple kinds can be comma-separated (no spaces).

-

When a kind is not found in the local schema mapping file, the CLI interactively prompts to run --update-schema (only in interactive CLI mode).

-
# Single kind
-class-generator -k Pod
-
-# Multiple kinds (processed in parallel)
-class-generator -k Deployment,Pod,ConfigMap
-
- -

When multiple kinds share the same Kind name but belong to different API groups (e.g., DNS from config.openshift.io and operator.openshift.io), separate files are generated with API group suffixes:

-
dns_config_openshift_io.py
-dns_operator_openshift_io.py
-
- -
-

-o, --output-file

-
- - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypePATH
Defaultocp_resources/<snake_case_kind>.py
RequiredNo
-

Override the output file path for the generated Python module. If not provided, the filename is derived from the Kind using convert_camel_case_to_snake_case.

-
class-generator -k Pod -o my_custom_pod.py
-
- -
-

Note: When generating multiple comma-separated kinds, the --output-file value applies to all kinds. For independent output paths, run separate commands.

-
-
-

--overwrite

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Overwrite an existing output file. Without this flag, if the target file already exists, a _TEMP.py suffixed file is created instead.

-

When overwriting, any user-added code blocks (code after the # End of generated code marker) and user imports are preserved in the regenerated file.

-
class-generator -k Pod --overwrite
-
- -
-

--dry-run

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Preview the generated output without writing any files. The generated Python code is printed to the console with syntax highlighting and line numbers using Rich.

-
class-generator -k Pod --dry-run
-
- -
-

--backup

-
- - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
Requires--regenerate-all or --overwrite
-

Create a timestamped backup of existing files before overwriting or regenerating. Backups are stored in .backups/backup-YYYYMMDD-HHMMSS/ preserving the original directory structure.

-
class-generator -k Pod --overwrite --backup
-
- -
-

Note: Using --backup without either --regenerate-all or --overwrite results in a constraint error.

-
-
-

Test Generation

-

--add-tests

-
- - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
Requires-k/--kind
-

Generate test files for the specified Kind and run them. This performs two actions:

-
    -
  1. Generates a test manifest in class_generator/tests/manifests/<Kind>/ and regenerates class_generator/tests/test_class_generator.py from the Jinja2 template.
  2. -
  3. Runs the generated test file using uv run --group tests pytest class_generator/tests/test_class_generator.py.
  4. -
-
class-generator -k Pod --add-tests
-
- -
-

Warning: --add-tests cannot be used without -k/--kind. Running class-generator --add-tests alone exits with a non-zero status.

-
-
-

Schema Management

-

--update-schema

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
RequiresConnected cluster; oc or kubectl in PATH
Mutually exclusive with--update-schema-for
-

Fetch all resource schemas from the connected cluster's OpenAPI v3 endpoints and update the local schema files:

-
    -
  • class_generator/schema/__resources-mappings.json (compressed as .json.gz)
  • -
  • class_generator/schema/_definitions.json
  • -
-

The update strategy depends on the cluster version:

-
- - - - - - - - - - - - - - - - -
Cluster VersionBehavior
Same or newer than last updateFull update — fetches all schemas, updates existing resources
Older than last updateIncremental — only adds missing resources, preserves existing schemas
-

When used alone, exits after updating. When combined with --generate-missing, continues to resource generation after the update.

-
# Update schema only
-class-generator --update-schema
-
-# Update schema then generate missing resources
-class-generator --update-schema --generate-missing
-
- -
-

Note: When --update-schema is used without --generate-missing, it cannot be combined with -k, --discover-missing, --coverage-report, --dry-run, --overwrite, -o, --add-tests, or --regenerate-all.

-
-
-

--update-schema-for

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypeSTRING
DefaultNone
RequiresConnected cluster; oc or kubectl in PATH
Mutually exclusive with--update-schema
-

Update the schema for a single resource Kind without affecting other resources. The Kind name is case-sensitive.

-

This fetches only the API paths relevant to the specified Kind, updates (or adds) its schema in the mapping file, and exits.

-
class-generator --update-schema-for LlamaStackDistribution
-
- -

Use cases:

-
    -
  • Connected to an older cluster but need to update a specific CRD
  • -
  • A new operator was installed and you need its resource schema
  • -
  • Refreshing just one resource without a full schema update
  • -
-

After updating, regenerate the class:

-
class-generator --update-schema-for LlamaStackDistribution
-class-generator -k LlamaStackDistribution --overwrite
-
- -

Errors:

-
- - - - - - - - - - - - - - - - -
ErrorCause
ResourceNotFoundErrorThe Kind is not found on the cluster (CRD not installed or name misspelled)
RuntimeErrorAPI paths not found or schema extraction failed
-
-

Note: Cannot be combined with -k, --discover-missing, --coverage-report, --generate-missing, or --regenerate-all.

-
-
-

Coverage and Discovery

-

--discover-missing

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Analyze resource coverage by comparing schema-mapped resources against implemented wrapper classes in ocp_resources/. Generates a console report showing coverage statistics and missing resources.

-
class-generator --discover-missing
-
- -
-

--coverage-report

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Generate a detailed coverage report showing:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MetricDescription
Total Resources in SchemaNumber of resource Kinds in the schema mapping
Auto-Generated ResourcesWrapper classes with the generated marker
CoveragePercentage of mapped resources with generated classes
Missing (Not Generated)Resources in schema but without generated classes
Manual ImplementationsResource classes without the generated marker
-
# Console output (default)
-class-generator --coverage-report
-
-# JSON output
-class-generator --coverage-report --json
-
- -
-

--json

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Output reports in JSON format instead of Rich console tables. Applies to --coverage-report, --discover-missing, and --generate-missing.

-

The JSON output structure:

-
{
-  "generated_resources": ["ConfigMap", "Deployment", "Pod"],
-  "manual_resources": ["VirtualMachine"],
-  "missing_resources": [{"kind": "Binding"}, {"kind": "ComponentStatus"}],
-  "coverage_stats": {
-    "total_in_mapping": 400,
-    "total_generated": 197,
-    "total_manual": 25,
-    "coverage_percentage": 49.25,
-    "missing_count": 203
-  }
-}
-
- -
class-generator --coverage-report --json
-
- -
-

--generate-missing

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Generate wrapper classes for all resources found in the schema mapping that do not yet have generated files. Each missing resource Kind is passed to class_generator() individually.

-

Can be combined with --update-schema to first refresh the schema, then generate all missing classes.

-
# Generate missing resources
-class-generator --generate-missing
-
-# Update schema first, then generate missing
-class-generator --update-schema --generate-missing
-
-# Dry run to preview
-class-generator --generate-missing --dry-run
-
- -
-

Batch Regeneration

-

--regenerate-all

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Regenerate all existing generated resource classes using the latest schemas. Only files containing the # Generated using marker in ocp_resources/ are processed.

-

Regeneration runs in parallel (up to 10 workers). User-added code (below # End of generated code) is preserved during regeneration.

-
# Regenerate all resources
-class-generator --regenerate-all
-
-# With backup
-class-generator --regenerate-all --backup
-
-# Dry run
-class-generator --regenerate-all --dry-run
-
-# Filter to specific resources
-class-generator --regenerate-all --filter "Pod*"
-
- -

Output summary:

-
Regeneration complete: 195 succeeded, 2 failed
-Backup files stored in: .backups/backup-20260705-143022
-
- -
-

--filter

-
- - - - - - - - - - - - - - - - - - - - -
PropertyValue
TypeSTRING
DefaultNone
Requires--regenerate-all
-

Filter which resources to regenerate using a glob pattern matched against the resource Kind name. Uses fnmatch semantics.

-
# Regenerate only Pod-related resources
-class-generator --regenerate-all --filter "Pod*"
-
-# Regenerate resources ending in "Service"
-class-generator --regenerate-all --filter "*Service"
-
-# Regenerate a specific resource
-class-generator --regenerate-all --filter "VirtualMachine"
-
- -
-

Verification

-

Verify that generated files still match what class-generator would produce. No file is modified.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OptionDescription
--verifyVerify the kinds passed via --kind
--verify-allVerify every generated resource; combine with --filter to narrow the scope
--verify-changedVerify only resources changed relative to --base; untouched files are not checked
--base TEXTRef compared against by --verify-changed. Defaults to the current branch's upstream, then to the clone's remote HEAD; the ref is fetched when the clone lacks it and the diff uses the merge base
--classification TEXTComma-separated drift classifications; only files matching them are reported and fail the run
--matchRequire the drift to be within --classification (a subset) instead of merely containing one of them
--exclude TEXTDrop files whose drift contains any of these classifications, leaving only drift that is safe to regenerate
--jobs INTEGERResources verified in parallel (default 10)
-

--classification, --match and --exclude require a verify mode; unknown -classifications are rejected with the list of valid values.

-

Every reported file is generator output, so the printed repair command is the plain -per-kind form:

-
class-generator --kind Deployment --overwrite
-
- -

Only files whose name class-generator writes are verified (<kind>.py, or -<kind>_<api-group>.py for a kind in several groups). A copied file under another name -is not generator output and is ignored.

-

Drift classifications

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ClassificationMeaning
blank linesOnly blank-line differences
docstring add / docstring deleteDocstring text differs
code add / code deleteCode lines differ
signature code addParameters were added (all generated parameters carry defaults, so this is not breaking)
signature code removeParameters were removed
breaking changeA caller-visible break: a parameter was removed, a parameter became required, a parameter is emitted under a different API key, the base class changed, or api_group/api_version changed
schema incompleteThe rendered class has no parameters while the file has some — an unresolvable $ref. Reported in addition to breaking change
file does not existThe expected generated file is missing (or was deleted on this branch)
formatting failedThe rendered output could not be formatted
not in schemaThe kind is absent from the resources mapping
-

code delete on its own is not breaking: it also covers guard lines removed when a -field becomes optional, rewritten statements and reflowed code.

-

Exit status

-

0 when everything matches, 1 when any file drifted or could not be verified.

-

CI

-

tox -e validate-generated runs --verify-changed with no arguments, so tox stays -CI-independent:

-
tox -e validate-generated
-
- -

Logging

-

-v, --verbose

-
- - - - - - - - - - - - - - - - -
PropertyValue
TypeFlag
DefaultFalse
-

Enable verbose output with debug-level logs. Sets DEBUG level on the following loggers:

-
    -
  • class_generator.core.schema
  • -
  • class_generator.core.generator
  • -
  • class_generator.core.coverage
  • -
  • class_generator.core.discovery
  • -
  • class_generator.cli
  • -
  • class_generator.utils
  • -
  • ocp_resources
  • -
-
class-generator -k Pod -v
-
- -
-

Constraint Rules

-

The CLI enforces the following option constraints:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConstraintRule
--update-schema ↔ --update-schema-forMutually exclusive
--update-schema (alone)Cannot combine with -k, --discover-missing, --coverage-report, --dry-run, --overwrite, -o, --add-tests, --regenerate-all
--update-schema-forCannot combine with -k, --discover-missing, --coverage-report, --generate-missing, --regenerate-all
--backupRequires --regenerate-all or --overwrite
--filterRequires --regenerate-all, --verify-all or --verify-changed
--verify / --verify-all / --verify-changedMutually exclusive, and cannot combine with --regenerate-all, --backup, --overwrite, --add-tests or --dry-run
--verifyRequires --kind
--classification / --excludeRequire a verify mode
--matchRequires --classification
No optionsExits with error — at least one action required
-
-

Execution Order

-

When multiple compatible options are specified together, the CLI processes them in this fixed order:

-
    -
  1. --update-schema-for (exits after completion)
  2. -
  3. --update-schema (exits unless --generate-missing is also set)
  4. -
  5. --coverage-report / --discover-missing / --generate-missing (coverage analysis and reporting)
  6. -
  7. --generate-missing (generates classes for missing resources)
  8. -
  9. --verify / --verify-all / --verify-changed (verification, exits after completion)
  10. -
  11. --regenerate-all (batch regeneration, exits after completion)
  12. -
  13. -k/--kind (normal kind generation)
  14. -
  15. --add-tests (test generation and execution)
  16. -
-
-

Exit Codes

-
- - - - - - - - - - - - - - - - - - - - -
CodeMeaning
0Success
1Generation failure, schema update failure, resource not found, or any kind in a batch failed
2Invalid CLI arguments or constraint violation
-
-

Programmatic API

-

The generation logic can be invoked directly from Python. See Generating New Resource Classes with class-generator for usage examples.

-

class_generator.core.generator.class_generator()

-
from class_generator.core.generator import class_generator
-
-generated_files: list[str] = class_generator(
-    kind="Pod",
-    overwrite=False,
-    dry_run=False,
-    output_file="",
-    output_dir="",
-    add_tests=False,
-    called_from_cli=True,
-    update_schema_executed=False,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
kindstr(required)Kubernetes resource Kind
overwriteboolFalseOverwrite existing files
dry_runboolFalsePreview output without writing files
output_filestr""Specific output file path
output_dirstr""Output directory (defaults to ocp_resources)
add_testsboolFalseGenerate test files
called_from_cliboolTrueEnables interactive prompts when True
update_schema_executedboolFalseWhether schema update was already performed
-

Returns: list[str] — List of generated file paths. Empty list if the kind is not found in the schema mapping (when called_from_cli=False).

-

Raises:

-
    -
  • RuntimeError — Kind not found after schema update, or user declined schema update
  • -
  • ValueError — Generated filename contains invalid patterns (single-letter segments)
  • -
-
-

class_generator.core.generator.generate_resource_file_from_dict()

-
from class_generator.core.generator import generate_resource_file_from_dict
-
-orig_filename, generated_filename = generate_resource_file_from_dict(
-    resource_dict={"kind": "Pod", ...},
-    overwrite=False,
-    dry_run=False,
-    output_file="",
-    add_tests=False,
-    output_file_suffix="",
-    output_dir="",
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
resource_dictdict[str, Any](required)Dictionary containing parsed resource information
overwriteboolFalseOverwrite existing files
dry_runboolFalsePreview without writing
output_filestr""Specific output file path
add_testsboolFalseGenerate test files under class_generator/tests/manifests/
output_file_suffixstr""Suffix appended to filename (for API group disambiguation)
output_dirstr""Output directory (defaults to ocp_resources)
-

Returns: tuple[str, str] — (original_filename, generated_filename). These differ when a _TEMP.py file is created.

-
-

class_generator.core.schema.update_kind_schema()

-
from class_generator.core.schema import update_kind_schema
-
-update_kind_schema(client=None)
-
- -
- - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientstr \| NoneNonePath to oc/kubectl binary. Auto-detected if None.
-

Raises:

-
    -
  • ClusterVersionError — Cannot determine cluster version
  • -
  • RuntimeError — Failed to fetch OpenAPI v3 index
  • -
  • OSError — Failed to write schema files
  • -
-
-

class_generator.core.schema.update_single_resource_schema()

-
from class_generator.core.schema import update_single_resource_schema
-
-update_single_resource_schema(kind="LlamaStackDistribution", client=None)
-
- -
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
kindstr(required)Resource Kind name (case-sensitive)
clientstr \| NoneNonePath to oc/kubectl binary. Auto-detected if None.
-

Raises:

-
    -
  • ResourceNotFoundError — Kind not found on the cluster
  • -
  • RuntimeError — Schema extraction or save failed
  • -
-
-

class_generator.core.coverage.analyze_coverage()

-
from class_generator.core.coverage import analyze_coverage
-
-result: dict[str, Any] = analyze_coverage(resources_dir="ocp_resources")
-
- -
- - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
resources_dirstr"ocp_resources"Directory to scan for wrapper classes
-

Returns: dict[str, Any] with keys:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyTypeDescription
generated_resourceslist[str]Sorted list of auto-generated resource class names
manual_resourceslist[str]Sorted list of manually written resource class names
missing_resourceslist[dict]List of {"kind": "..."} for resources in schema but not generated
coverage_statsdictStatistics including total_in_mapping, total_generated, total_manual, coverage_percentage, missing_count
-
-

class_generator.core.discovery.discover_generated_resources()

-
from class_generator.core.discovery import discover_generated_resources
-
-resources: list[dict[str, Any]] = discover_generated_resources()
-
- -

Returns: list[dict[str, Any]] — Each dict contains:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyTypeDescription
pathstrFull path to the resource file
kindstrResource class name
filenamestrFile name without extension
has_user_codeboolWhether file contains user modifications below # End of generated code
-
-

class_generator.core.discovery.discover_cluster_resources()

-
from class_generator.core.discovery import discover_cluster_resources
-
-resources: dict[str, list[dict[str, Any]]] = discover_cluster_resources(
-    client=None,
-    api_group_filter=None,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClient \| NoneNoneKubernetes dynamic client. Creates one if None.
api_group_filterstr \| NoneNoneFilter by API group name
-

Returns: dict[str, list[dict[str, Any]]] — Mapping of API version to list of resource dicts (name, kind, namespaced).

-

Raises: ValueError — If client is not a DynamicClient instance.

-
-

Exceptions

-

class_generator.exceptions.ResourceNotFoundError

-
from class_generator.exceptions import ResourceNotFoundError
-
- -

Raised when a resource Kind is not found in the schema mapping or on the cluster.

-
- - - - - - - - - - - - - - -
AttributeTypeDescription
kindstrThe Kind that was not found
-

class_generator.core.schema.ClusterVersionError

-
from class_generator.core.schema import ClusterVersionError
-
- -

Raised when the cluster version cannot be determined (client binary missing, cluster unreachable, or authentication failure).

-
-

Schema Files

-

The CLI manages two schema files under class_generator/schema/:

-
- - - - - - - - - - - - - - - - - - - - -
FilePurpose
__resources-mappings.json.gzCompressed JSON mapping of lowercase Kind → list of schemas with GVK metadata and namespaced status
_definitions.jsonJSON Schema definitions for $ref resolution during validation
__cluster_version__.txtLast cluster version used for schema generation
-

See Schema Validation and Code Generation Architecture for details on how these files are structured and consumed.

-
-

Common Workflows

-

Generate a new resource class

-
class-generator -k MyCustomResource
-
- -

Update schema and regenerate all classes

-
class-generator --update-schema
-class-generator --regenerate-all --backup
-
- -

Add a new CRD to an older cluster

-
class-generator --update-schema-for MyNewCRD
-class-generator -k MyNewCRD
-
- -

CI/CD coverage check

-
class-generator --coverage-report --json > coverage.json
-
- -

Preview changes before writing

-
class-generator -k Pod --dry-run
-class-generator --regenerate-all --dry-run
-
- -
- - - - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/common-patterns.html b/docs/common-patterns.html deleted file mode 100644 index a4e7504d80..0000000000 --- a/docs/common-patterns.html +++ /dev/null @@ -1,1212 +0,0 @@ - - - - - - Common Resource Patterns - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Common Resource Patterns

-

Copy-paste recipes for the most common Kubernetes and OpenShift resource operations using openshift-python-wrapper.

-
-

Note: All recipes assume you already have a connected client. See Connecting to Clusters for setup instructions.

-
-
# Preamble used by all recipes below
-from ocp_resources.resource import get_client
-
-client = get_client()
-
- -
-

Create and Run a Pod

-

Create a pod with a single container and wait for it to start.

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    client=client,
-    name="my-nginx",
-    namespace="default",
-    containers=[
-        {
-            "name": "nginx",
-            "image": "nginx:1.25",
-            "ports": [{"containerPort": 80}],
-        }
-    ],
-    restart_policy="Never",
-)
-pod.deploy()
-pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)
-
- -

The deploy() method creates the pod on the cluster. wait_for_status polls until the pod reaches Running. Use restart_policy="Always" for long-running pods.

-
-

Create a Pod as a Context Manager

-

Automatically clean up a pod when the block exits — ideal for tests.

-
from ocp_resources.pod import Pod
-
-with Pod(
-    client=client,
-    name="test-curl",
-    namespace="default",
-    containers=[
-        {
-            "name": "curl",
-            "image": "curlimages/curl:8.5.0",
-            "command": ["sleep", "3600"],
-        }
-    ],
-) as pod:
-    pod.wait_for_status(status=Pod.Status.RUNNING, timeout=60)
-    output = pod.execute(command=["curl", "-s", "http://httpbin.org/get"])
-    print(output)
-# Pod is automatically deleted here
-
- -

The context manager calls deploy() on enter and clean_up() on exit. Set teardown=False to skip automatic deletion.

-
-

Tip: See Creating and Managing Resources for all lifecycle management patterns.

-
-
-

Execute a Command Inside a Pod

-

Run a shell command inside an already-running pod and capture the output.

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True)
-output = pod.execute(command=["cat", "/etc/nginx/nginx.conf"], timeout=30)
-print(output)
-
- -

execute() streams output via the Kubernetes exec API. Use container="sidecar" to target a specific container in multi-container pods. Set ignore_rc=True to suppress errors from non-zero exit codes.

-
-

Get Pod Logs

-

Retrieve logs from a running or completed pod.

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True)
-
-# Full logs
-print(pod.log())
-
-# Last 50 lines
-print(pod.log(tail_lines=50))
-
-# Logs from a specific container
-print(pod.log(container="sidecar"))
-
- -

The log() method passes kwargs through to the Kubernetes read_namespaced_pod_log API, so all standard parameters like tail_lines, since_seconds, and container are supported.

-
-

Create a Deployment and Wait for Replicas

-

Deploy an application with multiple replicas and wait until they are all ready.

-
from ocp_resources.deployment import Deployment
-
-dep = Deployment(
-    client=client,
-    name="web-app",
-    namespace="default",
-    replicas=3,
-    selector={"matchLabels": {"app": "web-app"}},
-    template={
-        "metadata": {"labels": {"app": "web-app"}},
-        "spec": {
-            "containers": [
-                {
-                    "name": "app",
-                    "image": "nginx:1.25",
-                    "ports": [{"containerPort": 80}],
-                }
-            ]
-        },
-    },
-)
-dep.deploy()
-dep.wait_for_replicas(deployed=True, timeout=300)
-
- -

wait_for_replicas polls until availableReplicas == readyReplicas == updatedReplicas == spec.replicas. Pass deployed=False to wait for all replicas to be scaled down.

-
-

Scale a Deployment

-

Change the replica count of an existing deployment.

-
from ocp_resources.deployment import Deployment
-
-dep = Deployment(client=client, name="web-app", namespace="default", ensure_exists=True)
-
-# Scale up
-dep.scale_replicas(replica_count=5)
-dep.wait_for_replicas(deployed=True, timeout=300)
-
-# Scale down
-dep.scale_replicas(replica_count=1)
-dep.wait_for_replicas(deployed=True, timeout=300)
-
- -

scale_replicas patches the deployment's spec.replicas field. Always follow with wait_for_replicas to confirm the rollout completes.

-
-

Create a Service

-

Expose a deployment via a ClusterIP service.

-
from ocp_resources.service import Service
-
-svc = Service(
-    client=client,
-    name="web-app-svc",
-    namespace="default",
-    selector={"app": "web-app"},
-    ports=[
-        {
-            "protocol": "TCP",
-            "port": 80,
-            "targetPort": 80,
-        }
-    ],
-    type="ClusterIP",
-)
-svc.deploy()
-
- -

Change type to "NodePort" or "LoadBalancer" as needed. The selector must match labels on your target pods.

-
-

Create an OpenShift Route

-

Expose a service externally via an OpenShift Route.

-
from ocp_resources.route import Route
-
-route = Route(
-    client=client,
-    name="web-app-route",
-    namespace="default",
-    service="web-app-svc",
-)
-route.deploy()
-
-# Get the assigned hostname
-print(route.host)
-
- -

For TLS re-encrypt routes, pass destination_ca_cert="<PEM certificate string>". Access the exposed service name with route.exposed_service and the TLS termination type with route.termination.

-
-

Create a Namespace

-

Create a namespace (or use as a context manager for automatic cleanup).

-
from ocp_resources.namespace import Namespace
-
-# Simple creation
-ns = Namespace(client=client, name="test-ns")
-ns.deploy()
-
-# As a context manager (deleted on exit)
-with Namespace(client=client, name="ephemeral-ns") as ns:
-    # ... do work in the namespace ...
-    pass
-
- -

Namespace extends Resource (cluster-scoped), so no namespace parameter is needed.

-
-

Create a ConfigMap

-

Store configuration data for pods to consume.

-
from ocp_resources.config_map import ConfigMap
-
-cm = ConfigMap(
-    client=client,
-    name="app-config",
-    namespace="default",
-    data={
-        "DATABASE_URL": "postgres://db:5432/myapp",
-        "LOG_LEVEL": "info",
-    },
-)
-cm.deploy()
-
- -

Use binary_data for non-UTF-8 content. Set immutable=True to prevent changes after creation.

-
-

Create a Secret

-

Store sensitive data such as credentials.

-
from ocp_resources.secret import Secret
-
-secret = Secret(
-    client=client,
-    name="db-credentials",
-    namespace="default",
-    string_data={
-        "username": "admin",
-        "password": "s3cur3-pa$$word",
-    },
-    type="Opaque",
-)
-secret.deploy()
-
- -

Use data_dict instead of string_data if your values are already base64-encoded. Secret data is automatically hashed in log output for security.

-
-

Create a Job

-

Run a one-off task to completion.

-
from ocp_resources.job import Job
-
-job = Job(
-    client=client,
-    name="db-migration",
-    namespace="default",
-    backoff_limit=3,
-    restart_policy="Never",
-    containers=[
-        {
-            "name": "migrate",
-            "image": "my-app:latest",
-            "command": ["python", "manage.py", "migrate"],
-        }
-    ],
-)
-job.deploy()
-job.wait_for_condition(
-    condition=Job.Condition.COMPLETE,
-    status=Job.Condition.Status.TRUE,
-    timeout=300,
-)
-
- -

Set background_propagation_policy="Background" to delete leftover pods when the job is cleaned up. The backoff_limit controls how many times the job retries on failure.

-
-

Create a PersistentVolumeClaim

-

Request persistent storage for your workloads.

-
from ocp_resources.persistent_volume_claim import PersistentVolumeClaim
-
-pvc = PersistentVolumeClaim(
-    client=client,
-    name="app-data",
-    namespace="default",
-    accessmodes=PersistentVolumeClaim.AccessMode.RWO,
-    size="10Gi",
-    storage_class="gp3-csi",
-    volume_mode=PersistentVolumeClaim.VolumeMode.FILE,
-)
-pvc.deploy()
-pvc.wait_for_status(status=PersistentVolumeClaim.Status.BOUND, timeout=120)
-
- -

Use the AccessMode and VolumeMode constants instead of raw strings. The storage_class must match an available StorageClass on your cluster.

-
-

Create a DataVolume (KubeVirt / CDI)

-

Import a VM disk image into a PVC using the Containerized Data Importer.

-
from ocp_resources.datavolume import DataVolume
-
-dv = DataVolume(
-    client=client,
-    name="fedora-disk",
-    namespace="default",
-    source_dict={
-        "http": {
-            "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/40/Cloud/x86_64/images/Fedora-Cloud-Base-40-1.14.x86_64.qcow2"
-        }
-    },
-    size="30Gi",
-    storage_class="ocs-storagecluster-ceph-rbd-virtualization",
-    access_modes=DataVolume.AccessMode.RWX,
-    volume_mode=DataVolume.VolumeMode.BLOCK,
-    api_name="storage",
-)
-dv.deploy()
-dv.wait_for_dv_success(timeout=600)
-
- -

Always pass api_name="storage" explicitly — the default will change in a future release. Use source_dict={"blank": {}} for empty disks, or source_dict={"pvc": {"name": "source-pvc", "namespace": "default"}} for cloning.

-
    -
  • Clone a DataVolume: - python - dv_clone = DataVolume( - client=client, - name="fedora-disk-clone", - namespace="default", - source_dict={"pvc": {"name": "fedora-disk", "namespace": "default"}}, - size="30Gi", - storage_class="ocs-storagecluster-ceph-rbd-virtualization", - access_modes=DataVolume.AccessMode.RWX, - volume_mode=DataVolume.VolumeMode.BLOCK, - api_name="storage", - )
  • -
-
-

Tip: See Working with Virtual Machines (KubeVirt) for full VM lifecycle recipes.

-
-
-

Create a NetworkPolicy

-

Restrict traffic to pods matching a label selector.

-
from ocp_resources.network_policy import NetworkPolicy
-
-netpol = NetworkPolicy(
-    client=client,
-    name="allow-http-only",
-    namespace="default",
-    pod_selector={"matchLabels": {"app": "web-app"}},
-    policy_types=["Ingress"],
-    ingress=[
-        {
-            "ports": [{"protocol": "TCP", "port": 80}],
-            "from": [
-                {"namespaceSelector": {"matchLabels": {"env": "production"}}},
-            ],
-        }
-    ],
-)
-netpol.deploy()
-
- -

This allows TCP port 80 ingress only from namespaces labeled env=production. Omit ingress and set policy_types=["Ingress"] to deny all inbound traffic.

-
-

Create a NetworkAttachmentDefinition

-

Attach pods to a secondary network using Multus.

-
from ocp_resources.network_attachment_definition import LinuxBridgeNetworkAttachmentDefinition
-
-nad = LinuxBridgeNetworkAttachmentDefinition(
-    client=client,
-    name="br-100",
-    namespace="default",
-    bridge_name="br-100",
-    cni_type="cnv-bridge",
-    vlan=100,
-    mtu=1500,
-)
-nad.deploy()
-
- -

Variant classes are available for different bridge types:

-
    -
  • LinuxBridgeNetworkAttachmentDefinition — Linux bridge (cnv-bridge)
  • -
  • OvsBridgeNetworkAttachmentDefinition — OVS bridge
  • -
  • OVNOverlayNetworkAttachmentDefinition — OVN overlay (layer 2/3)
  • -
-
-

Create an RBAC Role and RoleBinding

-

Grant specific permissions to a service account within a namespace.

-
from ocp_resources.role import Role
-from ocp_resources.role_binding import RoleBinding
-from ocp_resources.service_account import ServiceAccount
-
-sa = ServiceAccount(
-    client=client,
-    name="app-sa",
-    namespace="default",
-)
-sa.deploy()
-
-role = Role(
-    client=client,
-    name="pod-reader",
-    namespace="default",
-    rules=[
-        {
-            "apiGroups": [""],
-            "resources": ["pods", "pods/log"],
-            "verbs": ["get", "list", "watch"],
-        }
-    ],
-)
-role.deploy()
-
-binding = RoleBinding(
-    client=client,
-    name="app-sa-pod-reader",
-    namespace="default",
-    subjects_kind="ServiceAccount",
-    subjects_name="app-sa",
-    subjects_namespace="default",
-    role_ref_kind="Role",
-    role_ref_name="pod-reader",
-)
-binding.deploy()
-
- -

This grants the app-sa service account read-only access to pods in the default namespace.

-
-

Create a ClusterRole and ClusterRoleBinding

-

Grant cluster-wide permissions to a service account.

-
from ocp_resources.cluster_role import ClusterRole
-from ocp_resources.cluster_role_binding import ClusterRoleBinding
-
-cr = ClusterRole(
-    client=client,
-    name="node-viewer",
-    rules=[
-        {
-            "apiGroups": [""],
-            "resources": ["nodes"],
-            "verbs": ["get", "list", "watch"],
-        }
-    ],
-)
-cr.deploy()
-
-crb = ClusterRoleBinding(
-    client=client,
-    name="app-sa-node-viewer",
-    cluster_role="node-viewer",
-    subjects=[
-        {
-            "kind": "ServiceAccount",
-            "name": "app-sa",
-            "namespace": "default",
-        }
-    ],
-)
-crb.deploy()
-
- -

ClusterRole and ClusterRoleBinding are cluster-scoped (Resource subclasses), so no namespace parameter is needed on the role or binding itself.

-
-

List and Filter Resources

-

Query existing resources using label and field selectors.

-
from ocp_resources.pod import Pod
-from ocp_resources.namespace import Namespace
-
-# List all pods in a namespace
-for pod in Pod.get(client=client, namespace="default"):
-    print(f"{pod.name} — {pod.status}")
-
-# Filter by label
-for pod in Pod.get(client=client, namespace="default", label_selector="app=web-app"):
-    print(pod.name)
-
-# Filter by field
-for pod in Pod.get(client=client, namespace="default", field_selector="status.phase=Running"):
-    print(pod.name)
-
-# List cluster-scoped resources (no namespace)
-for ns in Namespace.get(client=client, label_selector="env=staging"):
-    print(ns.name)
-
- -

The get() class method returns a generator of resource objects. Pass raw=True to get raw ResourceInstance objects instead.

-
-

Tip: See Querying and Watching Resources for advanced querying patterns.

-
-
-

Create a Resource from a YAML File

-

Load a resource definition from an existing YAML file.

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    client=client,
-    yaml_file="manifests/my-pod.yaml",
-)
-pod.deploy()
-
- -

When using yaml_file, the name and namespace are read from the file automatically. You cannot combine yaml_file with kind_dict.

-
-

Create a Resource from a Dictionary

-

Pass a raw Kubernetes resource dict directly.

-
from ocp_resources.deployment import Deployment
-
-dep = Deployment(
-    client=client,
-    kind_dict={
-        "apiVersion": "apps/v1",
-        "kind": "Deployment",
-        "metadata": {
-            "name": "from-dict-app",
-            "namespace": "default",
-        },
-        "spec": {
-            "replicas": 2,
-            "selector": {"matchLabels": {"app": "from-dict"}},
-            "template": {
-                "metadata": {"labels": {"app": "from-dict"}},
-                "spec": {
-                    "containers": [
-                        {
-                            "name": "app",
-                            "image": "nginx:1.25",
-                        }
-                    ],
-                },
-            },
-        },
-    },
-)
-dep.deploy()
-
- -

Using kind_dict bypasses all constructor logic in to_dict() — the dictionary is sent as-is to the API. This is useful when migrating existing manifests.

-
-

Handle Resources with Duplicate API Groups

-

Some Kubernetes resource kinds (e.g., DNS, Ingress, Gateway) exist in multiple API groups. The wrapper provides separate modules for each variant.

-
# DNS from config.openshift.io (cluster DNS config)
-from ocp_resources.dns_config_openshift_io import DNS as DNSConfig
-
-dns_config = DNSConfig(client=client, name="cluster", ensure_exists=True)
-print(dns_config.instance.spec.baseDomain)
-
-# DNS from operator.openshift.io (CoreDNS operator)
-from ocp_resources.dns_operator_openshift_io import DNS as DNSOperator
-
-dns_operator = DNSOperator(client=client, name="default", ensure_exists=True)
-print(dns_operator.instance.spec.logLevel)
-
- -
# Ingress from config.openshift.io (cluster ingress config)
-from ocp_resources.ingress_config_openshift_io import Ingress as IngressConfig
-
-ingress = IngressConfig(client=client, name="cluster", ensure_exists=True)
-print(ingress.instance.spec.domain)
-
-# Ingress from networking.k8s.io (K8s Ingress rules)
-from ocp_resources.ingress_networking_k8s_io import Ingress as K8sIngress
-
-k8s_ingress = K8sIngress(
-    client=client,
-    name="my-app-ingress",
-    rules=[
-        {
-            "host": "app.example.com",
-            "http": {
-                "paths": [
-                    {
-                        "path": "/",
-                        "pathType": "Prefix",
-                        "backend": {"service": {"name": "web-app-svc", "port": {"number": 80}}},
-                    }
-                ],
-            },
-        }
-    ],
-)
-
- -

The naming convention for duplicate-kind modules is <kind>_<api_group_snake_case>.py. Use Python import aliases (as) to distinguish them in your code.

-

Common duplicate kinds and their modules:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KindModuleAPI Group
DNSdns_config_openshift_ioconfig.openshift.io
DNSdns_operator_openshift_iooperator.openshift.io
Ingressingress_config_openshift_ioconfig.openshift.io
Ingressingress_networking_k8s_ionetworking.k8s.io
Gatewaygatewaynetworking.istio.io
Gatewaygateway_gateway_networking_k8s_iogateway.networking.k8s.io
Gatewaygateway_networking_istio_ionetworking.istio.io
-
-

Warning: Importing the wrong module will target the wrong API group, causing NotFoundError or unexpected behavior. Always verify the api_group attribute on your resource class.

-
-
-

Manage Multiple Similar Resources with ResourceList

-

Create N copies of a resource with auto-numbered names.

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import ResourceList
-
-with ResourceList(
-    resource_class=Namespace,
-    num_resources=3,
-    client=client,
-    name="perf-test-ns",
-) as namespaces:
-    # Creates: perf-test-ns-1, perf-test-ns-2, perf-test-ns-3
-    for ns in namespaces:
-        print(ns.name)
-# All 3 namespaces are deleted on exit
-
- -
-

Tip: See Managing Bulk Resources with ResourceList for NamespacedResourceList and other bulk patterns.

-
-
-

Deploy One Resource Per Namespace with NamespacedResourceList

-

Create an identical namespaced resource in each of several namespaces.

-
from ocp_resources.namespace import Namespace
-from ocp_resources.config_map import ConfigMap
-from ocp_resources.resource import ResourceList, NamespacedResourceList
-
-with ResourceList(
-    resource_class=Namespace,
-    num_resources=3,
-    client=client,
-    name="team-ns",
-) as namespaces:
-    with NamespacedResourceList(
-        resource_class=ConfigMap,
-        namespaces=namespaces,
-        client=client,
-        name="shared-config",
-        data={"REGION": "us-east-1"},
-    ) as configmaps:
-        # One ConfigMap "shared-config" in each of team-ns-1, team-ns-2, team-ns-3
-        for cm in configmaps:
-            print(f"{cm.namespace}/{cm.name}")
-
- -

NamespacedResourceList requires a ResourceList of Namespace objects. All resources are cleaned up in reverse order on exit.

-
-

Temporarily Edit a Resource with ResourceEditor

-

Apply temporary patches during a test and automatically restore original values.

-
from ocp_resources.resource import ResourceEditor
-from ocp_resources.config_map import ConfigMap
-
-cm = ConfigMap(client=client, name="app-config", namespace="default", ensure_exists=True)
-
-with ResourceEditor(
-    patches={cm: {"data": {"LOG_LEVEL": "debug"}}},
-    action="update",
-):
-    # LOG_LEVEL is now "debug"
-    print(cm.instance.data.LOG_LEVEL)
-# Original LOG_LEVEL is restored here
-
- -

ResourceEditor backs up original values on enter and restores them on exit. Use action="replace" when you need to remove fields entirely rather than patch them.

-
-

Tip: See Editing Resources Temporarily with ResourceEditor for complete details.

-
-
-

Validate a Resource Before Creating It

-

Catch schema errors before submitting to the API server.

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ValidationError
-
-pod = Pod(
-    client=client,
-    name="validated-pod",
-    namespace="default",
-    containers=[
-        {
-            "name": "app",
-            "image": "nginx:1.25",
-        }
-    ],
-    schema_validation_enabled=True,  # Auto-validate on create()
-)
-
-# Manual validation
-try:
-    pod.validate()
-    print("Resource is valid")
-except ValidationError as e:
-    print(f"Validation failed: {e}")
-
-# Or validate a raw dict without instantiation
-try:
-    Pod.validate_dict({
-        "apiVersion": "v1",
-        "kind": "Pod",
-        "metadata": {"name": "test"},
-        "spec": {"containers": [{"name": "app", "image": "nginx"}]},
-    })
-except ValidationError as e:
-    print(f"Dict validation failed: {e}")
-
- -

When schema_validation_enabled=True, create() and update_replace() automatically validate before sending to the API. Validation requires OpenAPI schemas to be available.

-
-

Tip: See Validating Resources Against OpenAPI Schemas for schema setup and troubleshooting.

-
-
-

Check If a Resource Exists

-

Verify a resource exists before operating on it.

-
from ocp_resources.deployment import Deployment
-
-dep = Deployment(client=client, name="web-app", namespace="default")
-
-if dep.exists:
-    print(f"Deployment exists, status: {dep.instance.status.availableReplicas} replicas available")
-else:
-    print("Deployment not found")
-
- -

exists returns the resource instance if found or None if not. For fail-fast behavior, use ensure_exists=True in the constructor — it raises ResourceNotFoundError immediately if the resource is missing.

-
-

Watch Resource Events

-

Stream events related to a specific resource.

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True)
-
-# Watch events for this pod (10 second window)
-for event in pod.events(timeout=10):
-    ev = event["object"]
-    print(f"[{ev.type}] {ev.reason}: {ev.message}")
-
- -

Use field_selector to narrow results further, for example: field_selector="type==Warning". The events() method automatically filters by involvedObject.name.

-
-

List Recent Events in a Namespace

-

Get existing events (not a watch stream) from the last N seconds.

-
from ocp_resources.event import Event
-
-# Warning events from the last 5 minutes
-events = Event.list(
-    client=client,
-    namespace="default",
-    field_selector="type==Warning",
-    since_seconds=300,
-)
-for ev in events:
-    print(f"[{ev.reason}] {ev.message}")
-
- -

Event.list() returns a sorted list (most recent first), unlike Event.get() which is a live watch stream.

-
-

Wait for a Resource Condition

-

Block until a resource reaches a specific condition.

-
from ocp_resources.deployment import Deployment
-
-dep = Deployment(client=client, name="web-app", namespace="default", ensure_exists=True)
-
-dep.wait_for_condition(
-    condition="Available",
-    status="True",
-    timeout=300,
-)
-
- -

Use stop_condition to fail fast if an unrecoverable condition is detected:

-
dep.wait_for_condition(
-    condition="Available",
-    status="True",
-    timeout=300,
-    stop_condition="ReplicaFailure",
-    stop_status="True",
-)
-
- -
-

Tip: See Waiting for Resource Conditions and Status for advanced waiting patterns.

-
-
-

Get a Resource's YAML Representation

-

Dump the intended resource dict as YAML (useful for debugging).

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    client=client,
-    name="debug-pod",
-    namespace="default",
-    containers=[{"name": "app", "image": "busybox", "command": ["sleep", "3600"]}],
-)
-print(pod.to_yaml())
-
- -

to_yaml() calls to_dict() internally and returns the YAML string. This shows what would be sent to the API, not the live state.

-
-

Get the Pod's Node and IP

-

Access commonly needed runtime properties of a pod.

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True)
-
-# Node where the pod is running
-print(f"Node: {pod.node.name}")
-
-# Pod IP address
-print(f"IP: {pod.ip}")
-
- -

The node property returns a Node resource object. The ip property reads from status.podIP.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/connecting-to-clusters.html b/docs/connecting-to-clusters.html deleted file mode 100644 index 0677eaf167..0000000000 --- a/docs/connecting-to-clusters.html +++ /dev/null @@ -1,722 +0,0 @@ - - - - - - Connecting to Clusters - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Connecting to Clusters

-

Establish authenticated connections to Kubernetes and OpenShift clusters so you can create, query, and manage resources with openshift-python-wrapper.

-

Prerequisites

- -

Quick Example

-
from ocp_resources.resource import get_client
-from ocp_resources.namespace import Namespace
-
-# Connect using default kubeconfig (~/.kube/config or $KUBECONFIG)
-client = get_client()
-
-# Use the client with any resource
-for ns in Namespace.get(client=client):
-    print(ns.name)
-
- -

Creating a Client

-

All cluster connections go through get_client(), which returns a Kubernetes DynamicClient. Pass this client to every resource you create or query.

-

Using the Default Kubeconfig

-

If you have a kubeconfig at ~/.kube/config or the KUBECONFIG environment variable is set, no arguments are needed:

-
from ocp_resources.resource import get_client
-
-client = get_client()
-
- -

The lookup order is:

-
    -
  1. The config_file parameter (if provided)
  2. -
  3. The KUBECONFIG environment variable
  4. -
  5. ~/.kube/config
  6. -
-

Using a Specific Kubeconfig File

-
client = get_client(config_file="/path/to/my/kubeconfig")
-
- -

Selecting a Context

-

If your kubeconfig has multiple contexts, select one by name:

-
client = get_client(config_file="/path/to/kubeconfig", context="my-staging-cluster")
-
- -

Authenticating with a Bearer Token

-

Connect to a cluster API server directly using a host URL and token:

-
client = get_client(
-    host="https://api.my-cluster.example.com:6443",
-    token="sha256~my-bearer-token",
-)
-
- -

Authenticating with Username and Password (Basic Auth)

-

For OpenShift clusters that support OAuth-based basic authentication:

-
client = get_client(
-    host="https://api.my-cluster.example.com:6443",
-    username="my-user",
-    password="my-password",
-)
-
- -
-

Note: Basic auth requires all three parameters: host, username, and password. This uses OpenShift's OAuth flow internally — it is not plain HTTP Basic Authentication.

-
-

Using a Kubeconfig Dictionary

-

Pass a kubeconfig as a Python dictionary instead of a file path:

-
config_dict = {
-    "apiVersion": "v1",
-    "kind": "Config",
-    "clusters": [{"name": "my-cluster", "cluster": {"server": "https://api.example.com:6443"}}],
-    "users": [{"name": "my-user", "user": {"token": "my-token"}}],
-    "contexts": [{"name": "my-context", "context": {"cluster": "my-cluster", "user": "my-user"}}],
-    "current-context": "my-context",
-}
-
-client = get_client(config_dict=config_dict)
-
- -

Passing the Client to Resources

-

Once you have a client, pass it to resource constructors and class methods:

-
from ocp_resources.resource import get_client
-from ocp_resources.namespace import Namespace
-from ocp_resources.pod import Pod
-
-client = get_client()
-
-# Creating a resource
-ns = Namespace(client=client, name="my-namespace")
-ns.deploy()
-
-# Querying resources
-for pod in Pod.get(client=client, namespace="my-namespace"):
-    print(pod.name)
-
-# Using context managers
-with Namespace(client=client, name="temp-namespace") as ns:
-    ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-
- -
-

Warning: The client parameter will become mandatory in the next major release. Always pass it explicitly. If omitted, the library creates a client automatically using the default kubeconfig, but this triggers a FutureWarning.

-
-

Environment Variables

-

get_client() respects several environment variables automatically:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
VariablePurposeDefault
KUBECONFIGPath to the kubeconfig file~/.kube/config
HTTPS_PROXYHTTPS proxy for cluster connectionsNone
HTTP_PROXYHTTP proxy (used if HTTPS_PROXY is not set)None
-
-

Tip: HTTPS_PROXY takes precedence over HTTP_PROXY when both are set.

-
-

For other environment variables that control logging, resource reuse, and teardown behavior, see Environment Variables and Configuration.

-

Advanced Usage

-

Disabling TLS Verification

-

For development clusters with self-signed certificates:

-
client = get_client(
-    host="https://api.dev-cluster.example.com:6443",
-    token="sha256~my-token",
-    verify_ssl=False,
-)
-
- -
-

Warning: Never disable TLS verification in production environments.

-
-

In-Cluster Configuration

-

When your code runs inside a pod on the cluster (for example, in a CI/CD pipeline or operator), get_client() automatically falls back to in-cluster configuration if the kubeconfig-based connection fails:

-
# Inside a pod — no config_file or host needed
-client = get_client()
-
- -

The library first attempts to connect using the kubeconfig. If that fails with a connection error, it loads credentials from the pod's service account token mounted at /var/run/secrets/kubernetes.io/serviceaccount/.

-

Generating a Kubeconfig File from a Token Connection

-

When you connect with host and token, you might need a kubeconfig file on disk (for example, to pass to external tools). Use generate_kubeconfig=True:

-
client = get_client(
-    host="https://api.my-cluster.example.com:6443",
-    token="sha256~my-token",
-    generate_kubeconfig=True,
-)
-
-# Access the generated kubeconfig path
-print(client.kubeconfig)
-
- -

The generated kubeconfig file:

-
    -
  • Is written to a temporary file with 0o600 permissions
  • -
  • Is automatically cleaned up when the process exits
  • -
-

If you already passed a config_file, the generate_kubeconfig flag reuses that file path instead of creating a new one.

-

Passing a Custom Client Configuration

-

For fine-grained control over TLS settings, timeouts, or proxy configuration, pass a kubernetes.client.Configuration object:

-
import kubernetes
-from ocp_resources.resource import get_client
-
-config = kubernetes.client.Configuration()
-config.verify_ssl = False
-config.proxy = "http://my-proxy:8080"
-
-client = get_client(
-    host="https://api.my-cluster.example.com:6443",
-    token="sha256~my-token",
-    client_configuration=config,
-)
-
- -

Connecting to Multiple Clusters

-

Create separate clients for each cluster and pass them to different resources:

-
from ocp_resources.resource import get_client
-from ocp_resources.namespace import Namespace
-
-prod_client = get_client(config_file="/path/to/prod-kubeconfig")
-staging_client = get_client(config_file="/path/to/staging-kubeconfig")
-
-# Query namespaces on both clusters
-prod_namespaces = list(Namespace.get(client=prod_client))
-staging_namespaces = list(Namespace.get(client=staging_client))
-
- -

Using the Fake Client for Testing

-

For unit tests that don't need a real cluster, pass fake=True:

-
client = get_client(fake=True)
-
- -

This returns a fake client that simulates Kubernetes API operations in memory. See Testing Without a Cluster Using the Fake Client for full details.

-

get_client() API Reference

-
from ocp_resources.resource import get_client
-
-get_client(
-    config_file: str | None = None,
-    config_dict: dict | None = None,
-    context: str | None = None,
-    client_configuration: kubernetes.client.Configuration | None = None,
-    persist_config: bool = True,
-    temp_file_path: str | None = None,
-    try_refresh_token: bool = True,
-    username: str | None = None,
-    password: str | None = None,
-    host: str | None = None,
-    verify_ssl: bool | None = None,
-    token: str | None = None,
-    fake: bool = False,
-    generate_kubeconfig: bool = False,
-) -> DynamicClient
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
config_filestr \| NonePath to a kubeconfig file
config_dictdict \| NoneKubeconfig as a Python dictionary
contextstr \| NoneName of the kubeconfig context to use
client_configurationConfiguration \| NoneCustom kubernetes.client.Configuration object
persist_configboolWhether to persist config changes back to the kubeconfig file
temp_file_pathstr \| NonePath for temporary kubeconfig file (used with config_dict)
try_refresh_tokenboolAttempt to refresh the authentication token (for in-cluster config)
usernamestr \| NoneUsername for basic authentication
passwordstr \| NonePassword for basic authentication
hoststr \| NoneCluster API server URL (e.g., https://api.example.com:6443)
verify_sslbool \| NoneSet to False to skip TLS certificate verification
tokenstr \| NoneBearer token for authentication
fakeboolReturn a fake in-memory client for testing
generate_kubeconfigboolSave connection info to a temporary kubeconfig file accessible via client.kubeconfig
-

Returns: DynamicClient — a Kubernetes dynamic client ready for resource operations.

-

Raises:

-
    -
  • kubernetes.config.ConfigException — if no valid kubeconfig is found and in-cluster config is unavailable
  • -
  • urllib3.exceptions.MaxRetryError — if the cluster is unreachable (before falling back to in-cluster config)
  • -
  • ClientWithBasicAuthError — if basic auth (username/password) fails
  • -
-

Authentication Method Priority

-

When multiple authentication parameters are provided, get_client() uses this priority order:

-
    -
  1. Basic auth — if username, password, and host are all provided
  2. -
  3. Token auth — if host and token are provided
  4. -
  5. Config dict — if config_dict is provided
  6. -
  7. Kubeconfig file — uses config_file, KUBECONFIG env var, or ~/.kube/config
  8. -
  9. In-cluster config — automatic fallback if the kubeconfig connection fails
  10. -
-

Troubleshooting

-

"FutureWarning: 'client' arg will be mandatory" -You are creating a resource without passing a client. Always call get_client() first and pass the result:

-
client = get_client()
-ns = Namespace(client=client, name="my-ns")  # Pass client explicitly
-
- -

Connection refused or timeout errors -Verify your cluster is reachable and the kubeconfig or token is correct. Check that HTTPS_PROXY / HTTP_PROXY environment variables are set correctly if you are behind a proxy.

-

"KUBECONFIG environment variable set in code is not detected" -The Kubernetes Python client reads KUBECONFIG at module import time. If you set it dynamically in your code, pass the path directly instead:

-
import os
-
-# Don't rely on: os.environ["KUBECONFIG"] = "/path/to/config"
-# Instead, pass it explicitly:
-client = get_client(config_file=os.environ.get("KUBECONFIG", "/path/to/config"))
-
- -

Basic auth fails with ClientWithBasicAuthError -Basic auth uses the OpenShift OAuth flow. Ensure your cluster has a well-known OAuth endpoint at /.well-known/oauth-authorization-server. This method does not work with vanilla Kubernetes clusters.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/creating-and-managing-resources.html b/docs/creating-and-managing-resources.html deleted file mode 100644 index 5665087865..0000000000 --- a/docs/creating-and-managing-resources.html +++ /dev/null @@ -1,890 +0,0 @@ - - - - - - Creating and Managing Resources - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Creating and Managing Resources

-

Deploy Kubernetes and OpenShift resources to your cluster, update them in place, and clean them up reliably using the openshift-python-wrapper's resource lifecycle methods.

-

Prerequisites

- -

Quick Example

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-# Create, use, and automatically delete a Namespace
-with Namespace(client=client, name="my-app") as ns:
-    print(f"{ns.name} is ready")
-# Namespace is deleted when the block exits
-
- -

Creating Resources

-

Every resource provides three ways to create it: calling deploy() directly, using a context manager, or loading from a YAML file.

-

Using deploy() and clean_up()

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="my-namespace")
-ns.deploy()
-
-# ... do work ...
-
-ns.clean_up()  # Deletes the resource and waits for deletion
-
- -

deploy() returns the resource instance, so you can chain:

-
ns = Namespace(client=client, name="my-namespace").deploy()
-
- -

Pass wait=True to deploy() to block until the resource reaches its ready state:

-
ns = Namespace(client=client, name="my-namespace")
-ns.deploy(wait=True)
-
- -

Using a Context Manager

-

The context manager calls deploy() on entry and clean_up() on exit, guaranteeing cleanup even if an exception occurs:

-
from ocp_resources.config_map import ConfigMap
-
-with ConfigMap(
-    client=client,
-    name="app-config",
-    namespace="my-namespace",
-    data={"database_url": "postgres://db:5432/myapp"},
-) as cm:
-    print(cm.instance.data)
-# ConfigMap is automatically deleted here
-
- -
-

Note: If cleanup fails during context manager exit, a ResourceTeardownError is raised so failures are never silently ignored.

-
-

Namespaced vs. Cluster-Scoped Resources

-

Resources that live within a namespace inherit from NamespacedResource and require both name and namespace:

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    client=client,
-    name="nginx",
-    namespace="my-namespace",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-)
-pod.deploy()
-
- -

Cluster-scoped resources (like Namespace) inherit from Resource and need only name:

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="my-namespace")
-ns.deploy()
-
- -

Creating Resources from a YAML File

-

Pass yaml_file to create any resource directly from a YAML manifest. The resource name and namespace are read from the file:

-
from ocp_resources.config_map import ConfigMap
-
-cm = ConfigMap(client=client, yaml_file="manifests/configmap.yaml")
-cm.deploy()
-
- -

yaml_file also accepts a StringIO object for in-memory YAML:

-
from io import StringIO
-from ocp_resources.config_map import ConfigMap
-
-yaml_content = StringIO("""
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: dynamic-config
-  namespace: my-namespace
-data:
-  setting: "enabled"
-""")
-
-cm = ConfigMap(client=client, yaml_file=yaml_content)
-cm.deploy()
-
- -

Creating Resources from a Dictionary

-

Use kind_dict to pass a pre-built dictionary. When kind_dict is provided, the resource class's to_dict() logic is bypassed entirely:

-
from ocp_resources.config_map import ConfigMap
-
-resource_dict = {
-    "apiVersion": "v1",
-    "kind": "ConfigMap",
-    "metadata": {
-        "name": "from-dict",
-        "namespace": "my-namespace",
-    },
-    "data": {"key": "value"},
-}
-
-cm = ConfigMap(client=client, kind_dict=resource_dict)
-cm.deploy()
-
- -
-

Warning: yaml_file and kind_dict are mutually exclusive. Passing both raises a ValueError.

-
-

Adding Labels and Annotations

-

Pass label and annotations at construction time:

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(
-    client=client,
-    name="labeled-ns",
-    label={"team": "platform", "env": "staging"},
-    annotations={"description": "Staging environment namespace"},
-)
-ns.deploy()
-
- -

Updating Resources

-

Patching with update()

-

update() sends a merge patch — it adds or modifies fields without removing existing ones:

-
cm = ConfigMap(
-    client=client,
-    name="app-config",
-    namespace="my-namespace",
-    ensure_exists=True,
-)
-
-cm.update(
-    resource_dict={
-        "data": {"new_key": "new_value"},
-    }
-)
-
- -

Replacing with update_replace()

-

update_replace() performs a full replacement of the resource. Use this when you need to remove fields that update() would leave untouched:

-
# Get the current state
-current = cm.instance.to_dict()
-
-# Remove a key
-current["data"].pop("old_key", None)
-
-# Replace the entire resource
-cm.update_replace(resource_dict=current)
-
- -
- - - - - - - - - - - - - - - - - - - - - - -
MethodHTTP VerbBehaviorUse When
update()PATCHMerges fields into existing resourceAdding or changing fields
update_replace()PUTReplaces entire resourceRemoving fields or doing full replacements
-

Deleting Resources

-

Using clean_up()

-
ns.clean_up()  # Deletes and waits for removal (default: wait=True)
-
- -

Control the deletion behavior:

-
# Delete without waiting
-ns.clean_up(wait=False)
-
-# Delete with a custom timeout (in seconds)
-ns.clean_up(timeout=120)
-
- -

clean_up() returns True if the resource was successfully deleted, False otherwise.

-

Using delete()

-

delete() is the lower-level method called by clean_up():

-
ns.delete(wait=True, timeout=240)
-
- -

You can also pass a custom body for delete options:

-
ns.delete(body={"propagationPolicy": "Background"})
-
- -
-

Tip: If you delete a resource that doesn't exist, delete() logs a warning and returns True instead of raising an error.

-
-

Controlling Teardown

-

Set teardown=False at construction to prevent automatic deletion in context managers:

-
with Namespace(client=client, name="persistent-ns", teardown=False) as ns:
-    print("This namespace will NOT be deleted on exit")
-
- -

Checking Resource State

-

Check If a Resource Exists

-
ns = Namespace(client=client, name="my-namespace")
-if ns.exists:
-    print("Namespace exists")
-
- -

Ensure a Resource Already Exists

-

Use ensure_exists=True to raise ResourceNotFoundError if the resource is not present on the cluster:

-
from ocp_resources.config_map import ConfigMap
-
-# Raises ResourceNotFoundError if the ConfigMap doesn't exist
-cm = ConfigMap(
-    client=client,
-    name="must-exist",
-    namespace="default",
-    ensure_exists=True,
-)
-
- -

Get the Live Instance

-

The instance property fetches the current state from the API server:

-
ns = Namespace(client=client, name="my-namespace", ensure_exists=True)
-print(ns.instance.metadata.labels)
-print(ns.status)
-
- -

Export as YAML

-
ns = Namespace(client=client, name="my-namespace")
-print(ns.to_yaml())
-
- -

For more on querying, watching, and status checks, see Querying and Watching Resources.

-

Dry Run Mode

-

Validate a resource against the API server without actually creating it:

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="dry-run-ns", dry_run=True)
-ns.deploy()  # Sends the request with ?dryRun=All — no resource is created
-
- -

Advanced Usage

-

Constructor Parameters Reference

-

All resource classes accept these common parameters:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClientNoneCluster client connection (will be required in next major release)
namestrNoneResource name
namespacestrNoneNamespace (namespaced resources only)
teardownboolTrueWhether clean_up() deletes the resource
yaml_filestrNonePath to a YAML manifest file
kind_dictdictNoneDictionary representation of the resource
delete_timeoutint240Timeout in seconds for delete operations
dry_runboolFalseServer-side dry run without persisting
labeldictNoneLabels to set on the resource
annotationsdictNoneAnnotations to set on the resource
ensure_existsboolFalseRaise if resource doesn't already exist
wait_for_resourceboolFalseWait for resource creation in context manager
schema_validation_enabledboolFalseValidate against OpenAPI schema on create/replace
hash_log_databoolTrueMask sensitive data in logs
-

For the full API reference, see Resource and NamespacedResource API.

-

Schema Validation on Create and Replace

-

Enable automatic schema validation so create() and update_replace() check your resource against its OpenAPI schema before sending it to the API server:

-
from ocp_resources.config_map import ConfigMap
-
-cm = ConfigMap(
-    client=client,
-    name="validated-cm",
-    namespace="default",
-    data={"key": "value"},
-    schema_validation_enabled=True,  # Validates before create()
-)
-cm.deploy()
-
- -

You can also validate on demand or validate a dictionary directly:

-
# Validate an existing resource instance
-cm.validate()
-
-# Validate a dictionary without creating a resource
-ConfigMap.validate_dict({
-    "apiVersion": "v1",
-    "kind": "ConfigMap",
-    "metadata": {"name": "test"},
-    "data": {"key": "value"},
-})
-
- -

Both methods raise ValidationError if validation fails. For more details, see Validating Resources Against OpenAPI Schemas.

-

Temporary Edits with ResourceEditor

-

ResourceEditor applies patches to existing resources and automatically restores original values when used as a context manager:

-
from ocp_resources.resource import ResourceEditor
-
-ns = Namespace(client=client, name="my-namespace", ensure_exists=True)
-
-with ResourceEditor(patches={ns: {"metadata": {"labels": {"temporary-label": "true"}}}}):
-    # Label is applied
-    assert ns.labels["temporary-label"] == "true"
-# Label is automatically removed
-
- -

ResourceEditor supports both update (merge patch) and replace actions:

-
with ResourceEditor(
-    patches={ns: {"metadata": {"labels": {"keep-only-this": "true"}}}},
-    action="replace",
-):
-    pass  # Full replacement applied, then restored on exit
-
- -

For complete coverage, see Editing Resources Temporarily with ResourceEditor.

-

Managing Multiple Resources

-

ResourceList — Create N Copies

-

Create multiple instances of the same resource type with indexed names:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import ResourceList
-
-with ResourceList(
-    client=client,
-    resource_class=Namespace,
-    name="test-ns",
-    num_resources=3,
-) as namespaces:
-    # Creates test-ns-1, test-ns-2, test-ns-3
-    for ns in namespaces:
-        print(ns.name)
-# All three namespaces are deleted on exit
-
- -

NamespacedResourceList — One Per Namespace

-

Create one resource in each namespace from a ResourceList:

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import NamespacedResourceList, ResourceList
-
-namespaces = ResourceList(
-    client=client,
-    resource_class=Namespace,
-    name="env",
-    num_resources=3,
-)
-
-with NamespacedResourceList(
-    client=client,
-    resource_class=Pod,
-    namespaces=namespaces,
-    name="worker",
-    containers=[{"name": "app", "image": "myapp:latest"}],
-) as pods:
-    for pod in pods:
-        print(f"{pod.name} in {pod.namespace}")
-
- -

For detailed patterns, see Managing Bulk Resources with ResourceList.

-

Debugging with Environment Variables

-

These environment variables control resource lifecycle behavior at runtime without code changes:

-
- - - - - - - - - - - - - - - - -
VariableEffect
REUSE_IF_RESOURCE_EXISTSSkip deploy() if the resource already exists
SKIP_RESOURCE_TEARDOWNSkip clean_up() to keep resources for debugging
-

Both accept a YAML-formatted dictionary. Spaces are significant:

-
# Skip creation of all Pods
-export REUSE_IF_RESOURCE_EXISTS="{Pod: {}}"
-
-# Skip creation of a specific Pod in a specific namespace
-export REUSE_IF_RESOURCE_EXISTS="{Pod: {my-pod: my-namespace}}"
-
-# Skip teardown for a Namespace and a Pod
-export SKIP_RESOURCE_TEARDOWN="{Namespace: {my-ns:}, Pod: {my-pod: my-namespace}}"
-
- -

For the full list of environment variables, see Environment Variables and Configuration.

-

Troubleshooting

-

MissingRequiredArgumentError on construction -You must provide either name, yaml_file, or kind_dict. For namespaced resources, both name and namespace are required unless using yaml_file or kind_dict.

-

ResourceTeardownError in context manager -The resource's clean_up() returned False, meaning deletion failed. Check cluster permissions and resource finalizers.

-

ValueError: yaml_file and resource_dict are mutually exclusive -You passed both yaml_file and kind_dict to a constructor. Use only one.

-

ResourceNotFoundError with ensure_exists=True -The resource doesn't exist on the cluster. Verify the name (and namespace for namespaced resources) are correct.

-

Resource not deleted after context manager exits -Check if teardown=False was passed. Also verify the SKIP_RESOURCE_TEARDOWN environment variable is not set.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/editing-resources-temporarily.html b/docs/editing-resources-temporarily.html deleted file mode 100644 index b6df433d3e..0000000000 --- a/docs/editing-resources-temporarily.html +++ /dev/null @@ -1,683 +0,0 @@ - - - - - - Editing Resources Temporarily with ResourceEditor - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Editing Resources Temporarily with ResourceEditor

-

Temporarily modify Kubernetes and OpenShift resources during tests and have their original values automatically restored when you're done. This is essential for test isolation—ensuring that changes made during a test don't leak into other tests or persist in the cluster.

-

Prerequisites

- -
-

Warning: The client used to retrieve resources must have sufficient privileges. Do not use an unprivileged user client with ResourceEditor.

-
-

Quick Example

-
from ocp_resources.resource import ResourceEditor
-from ocp_resources.node import Node
-
-node = Node(name="worker-0")
-
-with ResourceEditor(patches={node: {"metadata": {"labels": {"test-label": "true"}}}}):
-    # The node now has the label "test-label": "true"
-    # Run your test logic here...
-    pass
-
-# The label is automatically removed when the block exits
-
- -

That's it—when the with block ends (even if an exception occurs), the original resource state is restored.

-

Step-by-Step: Using ResourceEditor as a Context Manager

-

1. Import and identify your target resource

-
from ocp_resources.resource import ResourceEditor
-from ocp_resources.config_map import ConfigMap
-
-cm = ConfigMap(name="my-config", namespace="my-namespace")
-
- -

2. Define your patch

-

Patches are dictionaries that mirror the resource's YAML structure. Only include the fields you want to change:

-
patch = {"data": {"feature_flag": "enabled", "log_level": "debug"}}
-
- -

3. Wrap your test logic in a with block

-
with ResourceEditor(patches={cm: patch}):
-    # ConfigMap now has updated data fields
-    assert cm.instance.data.feature_flag == "enabled"
-    # Run tests that depend on the modified config...
-
- -

4. Original values are restored automatically

-

When the with block exits, the original data values are written back to the resource. Fields that didn't exist before the patch are removed (set to None in the restore payload, which tells the API to delete them).

-

Patching Multiple Resources at Once

-

Pass multiple resource-patch pairs in a single ResourceEditor:

-
from ocp_resources.resource import ResourceEditor
-from ocp_resources.namespace import Namespace
-from ocp_resources.config_map import ConfigMap
-
-ns = Namespace(name="test-ns")
-cm = ConfigMap(name="app-config", namespace="test-ns")
-
-patches = {
-    ns: {"metadata": {"labels": {"env": "test"}}},
-    cm: {"data": {"database_url": "postgres://test-db:5432"}},
-}
-
-with ResourceEditor(patches=patches):
-    # Both resources are patched; run tests here
-    pass
-# Both resources are restored to their original state
-
- -

Using update() and restore() Without a Context Manager

-

If you need finer control over when patches are applied and reverted—for example, in setup/teardown fixtures—use the update() and restore() methods directly:

-
from ocp_resources.resource import ResourceEditor
-from ocp_resources.config_map import ConfigMap
-
-cm = ConfigMap(name="app-config", namespace="default")
-
-editor = ResourceEditor(patches={cm: {"data": {"mode": "maintenance"}}})
-
-# Apply the patch and create backups
-editor.update(backup_resources=True)
-
-# ... run tests ...
-
-# Restore original values
-editor.restore()
-
- -
-

Note: When calling update() manually, pass backup_resources=True to ensure original values are captured. Without this flag, no backup is created and restore() will have nothing to revert.

-
-

Advanced Usage

-

Replace Instead of Patch

-

By default, ResourceEditor uses the "update" action, which sends a merge-patch (application/merge-patch+json). This merges your changes into the existing resource. If you need to replace the entire resource (for example, to remove fields that merge-patch can't delete), use action="replace":

-
from ocp_resources.resource import ResourceEditor
-
-with ResourceEditor(
-    patches={my_resource: {"spec": {"replicas": 3}}},
-    action="replace",
-):
-    # The resource is fully replaced, not merged
-    pass
-
- -
- - - - - - - - - - - - - - - - - - - -
ActionMethod UsedBehavior
"update" (default)Merge patchMerges patch into existing resource; cannot remove fields by omission
"replace"Full replacementReplaces the entire resource; fields not in the patch are removed
-
-

Warning: With action="replace", the patch must include all required fields for the resource, not just the fields you want to change. The replacement payload automatically includes metadata.name, metadata.namespace, metadata.resourceVersion, kind, and apiVersion.

-
-

Providing Your Own Backups

-

If you already know what the restore state should be (or need custom restore logic), pass user_backups to skip the automatic backup calculation:

-
from ocp_resources.resource import ResourceEditor
-
-custom_backup = {my_resource: {"spec": {"replicas": 1}}}
-
-with ResourceEditor(
-    patches={my_resource: {"spec": {"replicas": 5}}},
-    user_backups=custom_backup,
-):
-    # Resource scaled to 5 replicas
-    pass
-# Restored to 1 replica (your custom backup), regardless of what the original value was
-
- -

Inspecting Backups

-

Access the automatically generated backup data through the backups property:

-
editor = ResourceEditor(patches={my_resource: {"metadata": {"labels": {"env": "staging"}}}})
-editor.update(backup_resources=True)
-
-# See what was backed up
-print(editor.backups)
-# {<Resource>: {'metadata': {'labels': {'env': 'production'}}}}
-
-editor.restore()
-
- -

No-Op Detection

-

ResourceEditor automatically detects when a patch produces no actual changes. If the patch values already match the resource's current state, the resource is skipped entirely and a warning is logged:

-
ResourceEdit: no diff found in patch for my-resource -- skipping
-
- -

This prevents unnecessary API calls and avoids triggering watch events for no-op changes.

-

Automatic Retry on Conflicts

-

All patch and restore operations are wrapped with automatic retry logic. If a ConflictError occurs (for example, due to a concurrent update changing the resourceVersion), the operation is retried automatically with a 5-second sleep interval and a 30-second timeout.

-

API Reference

-

Constructor

-
ResourceEditor(
-    patches: dict,
-    action: str = "update",
-    user_backups: dict | None = None,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
patchesdict(required)Mapping of resource objects to patch dicts: {resource: {yaml_patch}}
actionstr"update"Either "update" (merge patch) or "replace" (full replacement)
user_backupsdict \| NoneNoneCustom backup dicts to use for restore instead of auto-generated ones
-

Properties

-
- - - - - - - - - - - - - - - - - - - -
PropertyReturn TypeDescription
backupsdictThe backup data captured for each patched resource: {resource: backup_dict}
patchesdictThe patches dict provided in the constructor
-

Methods

-
- - - - - - - - - - - - - - - - - - - -
MethodParametersDescription
update(backup_resources=False)backup_resources: boolApply patches. Set backup_resources=True to capture original values for later restore.
restore()—Re-apply the stored backup dicts to revert all patched resources.
-

Context Manager Protocol

-
- - - - - - - - - - - - - - - - -
MethodBehavior
__enter__()Calls update(backup_resources=True) and returns the ResourceEditor instance
__exit__()Calls restore() to revert all changes
-

Troubleshooting

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ProblemCauseSolution
Restore doesn't revert changesCalled update() without backup_resources=TrueAlways pass backup_resources=True when calling update() manually, or use the context manager (with block) which does this automatically
NotFoundError during backupResource cannot be found by name (known with some CRDs like ServiceMonitor)ResourceEditor handles this automatically by falling back to a field-selector query
ConflictError during patchAnother process modified the resource concurrentlyAutomatic retries handle this; if it persists beyond 30 seconds, check for controllers continuously modifying the resource
Patch has no effectThe resource already has the values in your patchThis is expected—ResourceEditor logs a warning and skips the resource. Check that you're patching the correct fields.
replace action failsPatch is missing required fieldsWhen using action="replace", provide all required spec fields since the entire resource is replaced
-
-

Tip: For creating and cleaning up resources (rather than editing existing ones), see Creating and Managing Resources. For managing groups of similar resources, see Managing Bulk Resources with ResourceList.

-
- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/environment-variables.html b/docs/environment-variables.html deleted file mode 100644 index 6f87461c56..0000000000 --- a/docs/environment-variables.html +++ /dev/null @@ -1,972 +0,0 @@ - - - - - - Environment Variables and Configuration - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Environment Variables and Configuration

-

This page documents all environment variables recognized by openshift-python-wrapper at runtime. These variables control logging behavior, resource lifecycle operations, proxy configuration, cluster connectivity, and sensitive data hashing.

-

Quick Reference

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Environment VariablePurposeDefault
KUBECONFIGPath to kubeconfig file~/.kube/config
HTTPS_PROXYHTTPS proxy URL for cluster connections(unset)
HTTP_PROXYHTTP proxy URL for cluster connections(unset)
OPENSHIFT_PYTHON_WRAPPER_LOG_LEVELLogging verbosity levelINFO
OPENSHIFT_PYTHON_WRAPPER_LOG_FILEPath to log output file"" (stdout)
OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATAEnable/disable hashing sensitive data in logstrue
REUSE_IF_RESOURCE_EXISTSSkip resource creation if already exists(unset)
SKIP_RESOURCE_TEARDOWNSkip resource deletion during teardown(unset)
-
-

Cluster Connection

-

KUBECONFIG

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr
Default~/.kube/config
Used byget_client()
Sourceocp_resources/resource.py
-

Path to the kubeconfig file used when creating a Kubernetes DynamicClient. Read when no explicit config_file, config_dict, host/token, or username/password arguments are passed to get_client().

-
-

Note: The standard kubernetes Python client reads KUBECONFIG at import time. If you set this variable in code (after import), openshift-python-wrapper handles it by explicitly passing the value to the client constructor. See Connecting to Clusters for all connection options.

-
-
export KUBECONFIG=/path/to/my/kubeconfig
-
- -
from ocp_resources.resource import get_client
-
-# Automatically uses $KUBECONFIG, or falls back to ~/.kube/config
-client = get_client()
-
- -
-

HTTPS_PROXY / HTTP_PROXY

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr
Default(unset — no proxy)
Used byget_client()
Sourceocp_resources/resource.py
-

Sets the proxy on the Kubernetes client configuration when no proxy is already configured on the client_configuration object. HTTPS_PROXY takes precedence over HTTP_PROXY when both are set.

-
export HTTPS_PROXY=http://proxy.example.com:8080
-
- -
from ocp_resources.resource import get_client
-
-# Proxy is automatically applied from environment
-client = get_client()
-
- -
-

Tip: If you pass a client_configuration object that already has a .proxy set, the environment variables are ignored.

-
-

Precedence order:

-
    -
  1. Explicit client_configuration.proxy (if already set)
  2. -
  3. HTTPS_PROXY environment variable
  4. -
  5. HTTP_PROXY environment variable
  6. -
-
-

Logging

-

OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr
DefaultINFO
Valid valuesDEBUG, INFO, WARNING, ERROR, CRITICAL
Used byResource._set_logger()
Sourceocp_resources/resource.py
-

Controls the log level for each resource instance's logger. The logger is created per-resource using the simple_logger library.

-
export OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG
-
- -
import os
-
-os.environ["OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL"] = "DEBUG"
-
-from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-pod = Pod(name="my-pod", namespace="default", client=client)
-# Logger on this Pod instance now uses DEBUG level
-
- -
-

OPENSHIFT_PYTHON_WRAPPER_LOG_FILE

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr
Default"" (empty string — logs to stdout)
Used byResource._set_logger()
Sourceocp_resources/resource.py
-

Redirects resource log output to a file. When unset or empty, logs are written to stdout.

-
export OPENSHIFT_PYTHON_WRAPPER_LOG_FILE=/var/log/ocp-wrapper.log
-
- -
import os
-
-os.environ["OPENSHIFT_PYTHON_WRAPPER_LOG_FILE"] = "/tmp/ocp-wrapper.log"
-
-from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-ns = Namespace(name="test-ns", client=client)
-# All log output for this resource is written to /tmp/ocp-wrapper.log
-
- -
-

Sensitive Data Hashing

-

OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr
Default"true"
Valid values"true", "false"
Used byResource.hash_resource_dict()
Sourceocp_resources/resource.py
-

Controls whether sensitive fields in resource dictionaries are replaced with "*******" when logged. When set to "false", sensitive data is logged in cleartext.

-

Hashing is applied to fields declared in each resource class's keys_to_hash property. The following built-in resources define sensitive keys:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
Resource ClassHashed Fields
Secretdata, stringData
ConfigMapdata, binaryData
SealedSecretspec>data, spec>encryptedData
VirtualMachinespec>template>spec>volumes[]>cloudInitNoCloud>userData
-
-

Warning: Setting this to "false" causes sensitive data (e.g., secrets, tokens) to appear in log output. Use only for debugging in secure environments.

-
-
# Disable hashing to see raw values in logs (debug only)
-export OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA=false
-
- -
import os
-
-os.environ["OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA"] = "false"
-
-from ocp_resources.secret import Secret
-from ocp_resources.resource import get_client
-
-client = get_client()
-secret = Secret(name="my-secret", namespace="default", client=client)
-# Logs will now show raw secret data instead of "*******"
-
- -
-

Note: Hashing also depends on the hash_log_data constructor parameter on each resource instance. Both must be enabled for hashing to occur. The hash_log_data parameter defaults to True. See Resource and NamespacedResource API for constructor details.

-
-

Interaction with hash_log_data parameter:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATAhash_log_data paramResult
"true" (default)True (default)Sensitive fields hashed
"true"FalseSensitive fields not hashed
"false"TrueSensitive fields not hashed
"false"FalseSensitive fields not hashed
-
-

Resource Reuse (Skip Creation)

-

REUSE_IF_RESOURCE_EXISTS

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr (YAML dict syntax)
Default(unset — no resources skipped)
Used byResource.deploy()
Sourceocp_resources/resource.py
-

When set, deploy() checks whether the target resource already exists on the cluster. If a match is found, the existing resource is returned without creating a new one. This is intended for debugging and iterative development workflows.

-
-

Warning: Spaces are significant in the value syntax. The value is parsed as YAML.

-
-

Value format:

-
{<Kind>: {<name>: <namespace>}}
-
- -

Matching rules:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PatternBehavior
{Pod: {}}Skip creation for all Pods (match by kind only)
{Pod: {my-pod:}}Skip creation for Pod named my-pod in any namespace
{Pod: {my-pod: my-ns}}Skip creation for Pod named my-pod in namespace my-ns only
{Kind1: {}, Kind2: {name: ns}}Multiple resource patterns combined
-

Examples:

-
# Skip all Pod creation if the pod already exists
-export REUSE_IF_RESOURCE_EXISTS="{Pod: {}}"
-
-# Skip specific pod in specific namespace
-export REUSE_IF_RESOURCE_EXISTS="{Pod: {my-pod: my-namespace}}"
-
-# Skip namespace and pod creation
-export REUSE_IF_RESOURCE_EXISTS="{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}"
-
- -
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-# If REUSE_IF_RESOURCE_EXISTS is set and a matching Pod exists,
-# deploy() returns the existing resource without calling create()
-pod = Pod(
-    name="my-pod",
-    namespace="my-namespace",
-    client=client,
-    containers=[{"name": "test", "image": "nginx"}],
-)
-pod.deploy()  # Skips creation if resource matches the env var pattern
-
- -
-

Note: The resource must actually exist on the cluster for the skip to take effect. If the resource does not exist, deploy() proceeds with normal creation.

-
-
-

Skip Teardown

-

SKIP_RESOURCE_TEARDOWN

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Typestr (YAML dict syntax)
Default(unset — no resources skipped)
Used byResource.clean_up()
Sourceocp_resources/resource.py
-

When set, clean_up() skips deletion for matching resources and returns True without calling delete(). This is intended for debugging — preserving resources on the cluster after tests finish.

-
-

Warning: Spaces are significant in the value syntax. The value is parsed as YAML.

-
-

Value format:

-

Uses the same YAML dict syntax as REUSE_IF_RESOURCE_EXISTS.

-

Matching rules:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
PatternBehavior
{Pod: {}}Skip teardown for all Pods
{Pod: {my-pod:}}Skip teardown for Pod named my-pod in any namespace
{Pod: {my-pod: my-ns}}Skip teardown for Pod named my-pod in namespace my-ns only
{Kind1: {}, Kind2: {name: ns}}Multiple resource patterns combined
-

Examples:

-
# Keep all Namespaces after test run
-export SKIP_RESOURCE_TEARDOWN="{Namespace: {}}"
-
-# Keep specific resources
-export SKIP_RESOURCE_TEARDOWN="{Namespace: {test-ns:}, Pod: {debug-pod: test-ns}}"
-
- -
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-pod = Pod(
-    name="debug-pod",
-    namespace="test-ns",
-    client=client,
-    containers=[{"name": "test", "image": "nginx"}],
-)
-pod.deploy()
-
-# If SKIP_RESOURCE_TEARDOWN is set with a matching pattern,
-# clean_up() returns True without deleting the resource
-pod.clean_up()  # Resource remains on the cluster
-
- -
-

Tip: Use REUSE_IF_RESOURCE_EXISTS and SKIP_RESOURCE_TEARDOWN together for a fast debug loop: skip creation if a resource already exists, and skip teardown so it persists between runs.

-
-
export REUSE_IF_RESOURCE_EXISTS="{Pod: {debug-pod: test-ns}}"
-export SKIP_RESOURCE_TEARDOWN="{Pod: {debug-pod: test-ns}}"
-
- -
-

Combined Usage Patterns

-

Debug iteration loop

-

Set both skip variables to avoid re-creating and tearing down resources between test runs:

-
export REUSE_IF_RESOURCE_EXISTS="{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}"
-export SKIP_RESOURCE_TEARDOWN="{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}"
-
- -

Full debug logging with unhashed data

-
export OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG
-export OPENSHIFT_PYTHON_WRAPPER_LOG_FILE=/tmp/debug.log
-export OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA=false
-
- -
-

Warning: This combination logs sensitive data in plaintext. Do not use in production or shared environments.

-
-

Custom kubeconfig with proxy

-
export KUBECONFIG=/path/to/custom/kubeconfig
-export HTTPS_PROXY=http://corporate-proxy.example.com:3128
-
- -
-

Programmatic Configuration Alternatives

-

Several behaviors controlled by environment variables can also be set programmatically via constructor parameters or function arguments. See Resource and NamespacedResource API for full constructor parameter documentation.

-
- - - - - - - - - - - - - - - - - - - - -
Environment VariableProgrammatic Alternative
KUBECONFIGget_client(config_file="/path/to/kubeconfig")
HTTPS_PROXY / HTTP_PROXYclient_configuration.proxy = "http://proxy:port"
OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATAResource(hash_log_data=False, ...) constructor parameter
-
-

Note: OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL, OPENSHIFT_PYTHON_WRAPPER_LOG_FILE, REUSE_IF_RESOURCE_EXISTS, and SKIP_RESOURCE_TEARDOWN can only be configured via environment variables.

-
- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/error-handling.html b/docs/error-handling.html deleted file mode 100644 index 2aacf3f313..0000000000 --- a/docs/error-handling.html +++ /dev/null @@ -1,1278 +0,0 @@ - - - - - - Error Handling and Exception Reference - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Error Handling and Exception Reference

-

All custom exceptions are defined in ocp_resources.exceptions and can be imported directly:

-
from ocp_resources.exceptions import (
-    ExecOnPodError,
-    MissingRequiredArgumentError,
-    ResourceTeardownError,
-    ValidationError,
-    ConditionError,
-    NNCPConfigurationFailed,
-    ClientWithBasicAuthError,
-    MissingResourceError,
-    MissingTemplateVariables,
-)
-
- -
-

Tip: All exceptions inherit from Python's built-in Exception class and can be caught with a bare except Exception if needed.

-
-
-

ExecOnPodError

-

Raised when a command executed inside a pod via Pod.execute() fails. See Executing Commands in Pods and Retrieving Logs for full usage details.

-

Import:

-
from ocp_resources.exceptions import ExecOnPodError
-
- -

Constructor Parameters:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
commandlist[str]The command that was executed
rcintReturn code (-1 if the return code could not be determined)
outstrStandard output captured from the command
errAnyStandard error output or error channel details
-

Attributes on the caught exception:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttributeTypeDescription
cmdlist[str]The command that was executed
rcintThe return code
outstrStandard output
errAnyStandard error or error channel dict
-

Raised by: Pod.execute() in the following scenarios:

-
    -
  • Command times out (stream response closes before completion) — rc=-1
  • -
  • Error channel returns no status — rc=-1
  • -
  • Error channel status is "Failure" — rc=-1, err contains the full error channel dict
  • -
  • Command exits with a non-zero exit code — rc contains the actual exit code
  • -
-

String representation:

-
Command execution failure: ['ls', '/nonexistent'], RC: 2, OUT: , ERR: ls: cannot access '/nonexistent'
-
- -

Example:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ExecOnPodError
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-
-try:
-    output = pod.execute(command=["ls", "/nonexistent"], timeout=30)
-except ExecOnPodError as e:
-    print(f"Command: {e.cmd}")
-    print(f"Return code: {e.rc}")
-    print(f"Stdout: {e.out}")
-    print(f"Stderr: {e.err}")
-
- -

Handling non-zero exit codes without exceptions:

-
# Use ignore_rc=True to suppress ExecOnPodError on non-zero exit codes
-output = pod.execute(command=["grep", "pattern", "/var/log/messages"], ignore_rc=True)
-
- -
-

MissingRequiredArgumentError

-

Raised when a resource is instantiated without providing required arguments and no yaml_file or kind_dict was supplied. This error is raised during to_dict(), which is called automatically by create() and deploy().

-

Import:

-
from ocp_resources.exceptions import MissingRequiredArgumentError
-
- -

Constructor Parameters:

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
argumentstrName of the missing required argument(s)
-

Attributes on the caught exception:

-
- - - - - - - - - - - - - - -
AttributeTypeDescription
argumentstrThe missing argument name
-

String representation:

-
Missing required argument/s. Either provide yaml_file, kind_dict or pass self.containers
-
- -

Raised by: The to_dict() method of resource subclasses when required spec fields are not provided. Examples of resources that raise this exception:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Resource ClassRequired Arguments
Podcontainers
StorageClassprovisioner
ClusterRoleBindingcluster_role
ClusterResourceQuotaquota, selector
CronJobjob_template, schedule
InferenceServicepredictor
IPAddressPooladdresses
ResourceQuotahard
UserDefinedNetworktopology
-
-

Note: This exception is not raised if you construct the resource using yaml_file or kind_dict, since those bypass the to_dict() logic.

-
-

Example:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import MissingRequiredArgumentError
-
-try:
-    pod = Pod(client=client, name="my-pod", namespace="default")
-    pod.deploy()
-except MissingRequiredArgumentError as e:
-    print(f"Missing: {e.argument}")
-    # Output: Missing required argument/s. Either provide yaml_file, kind_dict or pass self.containers
-
- -
-

ResourceTeardownError

-

Raised when a resource's clean_up() method returns False during context manager exit. This indicates the resource could not be deleted.

-

Import:

-
from ocp_resources.exceptions import ResourceTeardownError
-
- -

Constructor Parameters:

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
resourceAnyThe resource object that failed to tear down
-

Attributes on the caught exception:

-
- - - - - - - - - - - - - - -
AttributeTypeDescription
resourceAnyThe resource object that failed teardown
-

String representation:

-
Failed to execute teardown for resource <resource>
-
- -

Raised by: Resource.__exit__() — the context manager exit handler. Specifically, this is raised when:

-
    -
  1. The resource was created with teardown=True (the default).
  2. -
  3. The clean_up() method returns False.
  4. -
-

Example:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ResourceTeardownError
-
-try:
-    with Pod(
-        client=client,
-        name="my-pod",
-        namespace="default",
-        containers=[{"name": "nginx", "image": "nginx:latest"}],
-    ) as pod:
-        # Use pod...
-        pass
-    # clean_up() is called automatically here
-except ResourceTeardownError as e:
-    print(f"Could not delete: {e.resource}")
-
- -
-

Tip: Set teardown=False on the resource constructor if you do not want automatic cleanup on context manager exit, and thus never want this exception raised.

-
-
-

ValidationError

-

Raised when a resource fails schema validation against the OpenAPI specification. See Validating Resources Against OpenAPI Schemas for full validation guide.

-

Import:

-
from ocp_resources.exceptions import ValidationError
-
- -

Constructor Parameters:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
messagestr(required)Human-readable error description
pathstr""JSONPath to the invalid field (e.g., "spec.containers[0].image")
schema_errorAnyNoneOriginal jsonschema validation error for debugging
-

Attributes on the caught exception:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
AttributeTypeDescription
messagestrHuman-readable error message
pathstrJSONPath to the invalid field
schema_errorAnyOriginal jsonschema.ValidationError if available
-

String representation:

-
Validation error at 'spec.containers[0].image': Invalid type
-
- -

When path is empty:

-
Validation error: Field is required
-
- -

Raised by:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
MethodTrigger
resource.validate()Called explicitly by user
resource.create()When schema_validation_enabled=True
resource.update_replace()When schema_validation_enabled=True
Resource.validate_dict()Class method for validating raw dicts
-
-

Note: resource.update() does not trigger validation even when schema_validation_enabled=True, because updates send partial patches that would fail full schema validation.

-
-

Example — explicit validation:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ValidationError
-
-pod = Pod(client=client, name="my-pod", namespace="default", containers=[{"name": "nginx", "image": "nginx:latest"}])
-
-try:
-    pod.validate()
-except ValidationError as e:
-    print(f"Error: {e.message}")
-    print(f"Path: {e.path}")
-    if e.schema_error:
-        print(f"Original error: {e.schema_error}")
-
- -

Example — auto-validation on create:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ValidationError
-
-try:
-    pod = Pod(
-        client=client,
-        name="my-pod",
-        namespace="default",
-        containers=[{"name": "nginx", "image": "nginx:latest"}],
-        schema_validation_enabled=True,
-    )
-    pod.deploy()
-except ValidationError as e:
-    print(f"Invalid resource: {e}")
-
- -

Example — validate a raw dictionary:

-
from ocp_resources.deployment import Deployment
-from ocp_resources.exceptions import ValidationError
-
-deployment_dict = {
-    "apiVersion": "apps/v1",
-    "kind": "Deployment",
-    "metadata": {"name": "my-deploy"},
-    "spec": {"replicas": "three"},  # Wrong type — should be int
-}
-
-try:
-    Deployment.validate_dict(resource_dict=deployment_dict)
-except ValidationError as e:
-    print(f"Validation failed: {e}")
-
- -
-

ConditionError

-

Raised when a resource reaches an undesired stop condition during wait_for_condition(). See Waiting for Resource Conditions and Status for details.

-

Import:

-
from ocp_resources.exceptions import ConditionError
-
- -

Constructor Parameters: Standard Exception — accepts a single string message.

-

Raised by: Resource.wait_for_condition() when the stop_condition parameter matches before the desired condition is met.

-

wait_for_condition() parameters relevant to ConditionError:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
conditionstr(required)Condition type to wait for
statusstr(required)Expected status value
timeoutint300Maximum wait time in seconds
sleep_timeint1Polling interval in seconds
stop_conditionstr \| NoneNoneCondition type that should abort the wait
stop_statusstr"True"Status value for the stop condition
-

String representation:

-
Deployment my-deploy reached stop_condition 'Failed' in status 'True':
-{'type': 'Failed', 'status': 'True', 'reason': 'DeadlineExceeded', 'message': '...'}
-
- -
-

Note: When stop_condition is None (the default), ConditionError is never raised. Instead, TimeoutExpiredError is raised if the desired condition is not met within the timeout.

-
-

Example:

-
from ocp_resources.deployment import Deployment
-from ocp_resources.exceptions import ConditionError
-from timeout_sampler import TimeoutExpiredError
-
-deploy = Deployment(client=client, name="my-deploy", namespace="default")
-
-try:
-    deploy.wait_for_condition(
-        condition="Available",
-        status="True",
-        timeout=120,
-        stop_condition="Failed",
-        stop_status="True",
-    )
-except ConditionError as e:
-    print(f"Resource entered failure state: {e}")
-except TimeoutExpiredError:
-    print("Timed out waiting for condition")
-
- -
-

NNCPConfigurationFailed

-

Raised when a NodeNetworkConfigurationPolicy (NNCP) fails to configure on one or more nodes.

-

Import:

-
from ocp_resources.exceptions import NNCPConfigurationFailed
-
- -

Constructor Parameters: Standard Exception — accepts a single string message describing the failure reason and error details.

-

Raised by: NodeNetworkConfigurationPolicy.wait_for_status_success() in two scenarios:

-
- - - - - - - - - - - - - - - - -
ScenarioMessage Pattern
No matching node found"{name}. Reason: NoMatchingNode"
Configuration failed on nodes"Reason: FailedToConfigure\n{error_details}"
-
-

Note: When wait_for_status_success() catches TimeoutExpiredError or NNCPConfigurationFailed, it logs the error with node details and re-raises the exception.

-
-

Example:

-
from ocp_resources.node_network_configuration_policy import NodeNetworkConfigurationPolicy
-from ocp_resources.exceptions import NNCPConfigurationFailed
-from timeout_sampler import TimeoutExpiredError
-
-nncp = NodeNetworkConfigurationPolicy(
-    client=client,
-    name="my-nncp",
-    desired_state={"interfaces": [{"name": "eth1", "type": "ethernet", "state": "up"}]},
-)
-
-try:
-    nncp.deploy()
-    nncp.wait_for_status_success()
-except NNCPConfigurationFailed as e:
-    print(f"NNCP configuration failed: {e}")
-except TimeoutExpiredError:
-    print("NNCP configuration timed out")
-
- -
-

ClientWithBasicAuthError

-

Raised during OAuth-based client authentication when using username/password credentials to connect to an OpenShift cluster. See Connecting to Clusters for connection methods.

-

Import:

-
from ocp_resources.exceptions import ClientWithBasicAuthError
-
- -

Constructor Parameters: Standard Exception — accepts a single string message.

-

Raised by: client_configuration_with_basic_auth() in the following scenarios:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
ScenarioMessage
OAuth well-known endpoint not reachable"No well-known file found at endpoint"
Authorization code not returned after login"No authorization code found"
No authorization_endpoint in OAuth config"No authorization_endpoint found in well-known file"
Token exchange fails"Failed to authenticate with basic auth"
-

Example:

-
from ocp_resources.exceptions import ClientWithBasicAuthError
-from ocp_resources.resource import client_configuration_with_basic_auth
-
-import kubernetes
-
-configuration = kubernetes.client.Configuration()
-configuration.verify_ssl = False
-
-try:
-    api_client = client_configuration_with_basic_auth(
-        username="admin",
-        password="password",
-        host="https://api.cluster.example.com:6443",
-        configuration=configuration,
-    )
-except ClientWithBasicAuthError as e:
-    print(f"Authentication failed: {e}")
-
- -
-

MissingResourceError

-

Raised when a resource object fails to generate its internal representation.

-

Import:

-
from ocp_resources.exceptions import MissingResourceError
-
- -

Constructor Parameters:

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
namestrName of the resource that could not be generated
-

Attributes on the caught exception:

-
- - - - - - - - - - - - - - -
AttributeTypeDescription
resource_namestrThe resource name
-

String representation:

-
Failed to generate resource: my-resource
-
- -

Example:

-
from ocp_resources.exceptions import MissingResourceError
-
-try:
-    # Operations that may fail to generate a resource
-    ...
-except MissingResourceError as e:
-    print(f"Resource generation failed: {e.resource_name}")
-
- -
-

MissingTemplateVariables

-

Raised when rendering a YAML template and not all required template variables have been provided.

-

Import:

-
from ocp_resources.exceptions import MissingTemplateVariables
-
- -

Constructor Parameters:

-
- - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
varstrName of the missing template variable
templatestrPath to the template file
-

Attributes on the caught exception:

-
- - - - - - - - - - - - - - - - - - - -
AttributeTypeDescription
varstrThe missing variable name
templatestrThe template file path
-

String representation:

-
Missing variables image for template tests/manifests/vm.yaml
-
- -

Example:

-
from ocp_resources.exceptions import MissingTemplateVariables
-
-try:
-    result = generate_yaml_from_template(name="my-vm")
-    # If template also requires 'image', raises MissingTemplateVariables
-except MissingTemplateVariables as e:
-    print(f"Variable '{e.var}' missing in template '{e.template}'")
-
- -
-

Common Error-Handling Patterns

-

Catching Multiple Library Exceptions

-
from ocp_resources.exceptions import (
-    ExecOnPodError,
-    MissingRequiredArgumentError,
-    ResourceTeardownError,
-    ValidationError,
-)
-
-try:
-    with Pod(
-        client=client,
-        name="worker",
-        namespace="default",
-        containers=[{"name": "app", "image": "myapp:latest"}],
-        schema_validation_enabled=True,
-    ) as pod:
-        pod.execute(command=["python", "run_task.py"])
-except ValidationError as e:
-    print(f"Invalid resource spec: {e}")
-except ExecOnPodError as e:
-    print(f"Task failed (rc={e.rc}): {e.err}")
-except ResourceTeardownError as e:
-    print(f"Pod cleanup failed: {e}")
-except MissingRequiredArgumentError as e:
-    print(f"Missing field: {e.argument}")
-
- -

Safe Condition Waiting with Early Abort

-
from ocp_resources.exceptions import ConditionError
-from timeout_sampler import TimeoutExpiredError
-
-try:
-    resource.wait_for_condition(
-        condition="Ready",
-        status="True",
-        timeout=180,
-        stop_condition="Failed",
-        stop_status="True",
-    )
-except ConditionError:
-    # Resource entered a terminal failure state — no point waiting further
-    resource.clean_up()
-    raise
-except TimeoutExpiredError:
-    # Condition not met within timeout — may be transient
-    print("Resource is taking too long, investigating...")
-
- -

Ignoring Non-Critical Command Failures

-
from ocp_resources.exceptions import ExecOnPodError
-
-# Method 1: Use ignore_rc=True
-output = pod.execute(command=["grep", "ERROR", "/var/log/app.log"], ignore_rc=True)
-
-# Method 2: Catch and inspect the return code
-try:
-    output = pod.execute(command=["test", "-f", "/tmp/lockfile"])
-except ExecOnPodError as e:
-    if e.rc == 1:
-        print("Lock file does not exist — proceeding")
-    else:
-        raise  # Re-raise unexpected errors
-
- -

Validation Before Batch Deployment

-
from ocp_resources.exceptions import ValidationError
-
-resources = [pod1, pod2, deployment1, service1]
-
-# Pre-validate all resources before deploying any
-errors = []
-for resource in resources:
-    try:
-        resource.validate()
-    except ValidationError as e:
-        errors.append((resource.name, str(e)))
-
-if errors:
-    for name, err in errors:
-        print(f"  {name}: {err}")
-    raise SystemExit("Fix validation errors before deploying")
-
-for resource in resources:
-    resource.deploy()
-
- -
-

Exception Hierarchy Summary

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ExceptionParentModule
ExecOnPodErrorExceptionocp_resources.exceptions
MissingRequiredArgumentErrorExceptionocp_resources.exceptions
ResourceTeardownErrorExceptionocp_resources.exceptions
ValidationErrorExceptionocp_resources.exceptions
ConditionErrorExceptionocp_resources.exceptions
NNCPConfigurationFailedExceptionocp_resources.exceptions
ClientWithBasicAuthErrorExceptionocp_resources.exceptions
MissingResourceErrorExceptionocp_resources.exceptions
MissingTemplateVariablesExceptionocp_resources.exceptions
-
-

Warning: MissingResourceResError is deprecated and will be removed in a future release. Use MissingResourceError instead.

-
- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/generating-resource-classes.html b/docs/generating-resource-classes.html deleted file mode 100644 index efa8885788..0000000000 --- a/docs/generating-resource-classes.html +++ /dev/null @@ -1,716 +0,0 @@ - - - - - - Generating New Resource Classes with class-generator - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Generating New Resource Classes with class-generator

-

Scaffold Python wrapper classes for any Kubernetes or OpenShift custom resource definition (CRD) so you can manage those resources using the same patterns as built-in resources. The class-generator CLI introspects your cluster's OpenAPI schemas and produces ready-to-use Python files complete with typed constructors, docstrings, and to_dict() serialization.

-

Prerequisites

-
    -
  • openshift-python-wrapper installed (see Installing and Creating Your First Resource)
  • -
  • oc or kubectl CLI available on your PATH
  • -
  • An active connection to a Kubernetes or OpenShift cluster with admin privileges (for --kind generation and schema updates)
  • -
-

Quick Example

-

Generate a wrapper class for the Deployment resource:

-
class-generator -k Deployment
-
- -

This creates a Python file in ocp_resources/ with a fully typed class you can import and use immediately:

-
from ocp_resources.deployment import Deployment
-
-dep = Deployment(
-    name="my-app",
-    namespace="default",
-    replicas=3,
-    selector={"matchLabels": {"app": "my-app"}},
-    template={...},
-)
-dep.deploy()
-
- -

Step-by-Step: Generating a Resource Class

-

1. Generate the class file

-

Pass the CRD's Kind (case-sensitive) to --kind / -k:

-
class-generator -k StorageCluster
-
- -

The output file is automatically named using snake_case — ocp_resources/storage_cluster.py.

-

2. Preview without writing (dry run)

-

See exactly what would be generated without touching the filesystem:

-
class-generator -k StorageCluster --dry-run
-
- -

The rendered Python code is printed to the console with syntax highlighting.

-

3. Review the generated file

-

Every generated file contains:

-
    -
  • A class inheriting from Resource (cluster-scoped) or NamespacedResource (namespace-scoped)
  • -
  • Typed __init__ parameters extracted from the OpenAPI spec and status fields
  • -
  • A to_dict() method that serializes required and optional fields
  • -
  • A # End of generated code marker — any code you add below this line is preserved on regeneration
  • -
-

Example generated output for ConfigMap:

-
from typing import Any
-from ocp_resources.resource import NamespacedResource
-
-
-class ConfigMap(NamespacedResource):
-    """
-    ConfigMap holds configuration data for pods to consume.
-    """
-
-    api_version: str = NamespacedResource.ApiVersion.V1
-
-    def __init__(
-        self,
-        binary_data: dict[str, Any] | None = None,
-        data: dict[str, Any] | None = None,
-        immutable: bool | None = None,
-        **kwargs: Any,
-    ) -> None: ...
-
- -

4. Generate multiple kinds at once

-

Pass a comma-separated list (no spaces):

-
class-generator -k Pod,Service,ConfigMap
-
- -

Multiple kinds are generated in parallel for speed.

-

5. Overwrite an existing file

-

By default, existing files are not overwritten. Pass --overwrite to replace them:

-
class-generator -k Deployment --overwrite
-
- -

To create a timestamped backup before overwriting:

-
class-generator -k Deployment --overwrite --backup
-
- -

Backups are stored in .backups/backup-YYYYMMDD-HHMMSS/ preserving the original directory structure.

-

6. Write to a custom output path

-
class-generator -k MyCustomResource -o my_package/custom_resource.py
-
- -

Handling Duplicate Kinds

-

Some Kubernetes kinds exist in multiple API groups. For example, DNS exists in both config.openshift.io and operator.openshift.io. When this happens, the generator automatically creates separate files with group-based suffixes:

-
- - - - - - - - - - - - - - - - - - - -
KindAPI GroupGenerated File
DNSconfig.openshift.iodns_config_openshift_io.py
DNSoperator.openshift.iodns_operator_openshift_io.py
-

Both files define a class named DNS but with different api_group attributes. Import the one matching the API group you need.

-

Adding User Code to Generated Files

-

Any code you add below the # End of generated code marker is preserved when you regenerate the file with --overwrite. This is how built-in resources like ConfigMap include custom properties:

-
# End of generated code
-
-
-@property
-def keys_to_hash(self):
-    return ["data", "binaryData"]
-
- -

Custom imports you add at the top of the file are also preserved during regeneration.

-

Updating Schemas

-

The generator uses a local schema cache to resolve resource definitions. If a CRD is missing (e.g., you just installed a new operator), update the cache first.

-

Full schema update

-

Fetch all schemas from the connected cluster:

-
class-generator --update-schema
-
- -
-

Note: If connected to an older cluster, existing schemas are preserved. Only new or missing resources are added.

-
-

Single-resource schema update

-

Update just one resource without touching the rest:

-
class-generator --update-schema-for LlamaStackDistribution
-
- -

Then generate the class:

-
class-generator -k LlamaStackDistribution --overwrite
-
- -
-

Warning: --update-schema and --update-schema-for are mutually exclusive. Use one or the other.

-
-

Verifying Generated Files

-

A generated file is only trustworthy while it still matches what class-generator produces. ---verify* checks that without modifying anything: on drift it exits 1, lists each file -with its drift classification, and prints the regenerate command.

-
# every generated resource
-class-generator --verify-all
-
-# only the files this branch changed (compares against the branch's base, merge-base aware)
-class-generator --verify-changed
-
-# what a PR touched, is what CI should check
-tox -e validate-generated
-
- -

Narrow the report when you only care about a specific kind of drift:

-
# only files whose drift is blank lines and/or added parameters, nothing else
-class-generator --verify-all --classification "blank lines,signature code add" --match
-
-# everything that is safe to regenerate - drops caller-breaking drift
-class-generator --verify-all --exclude "breaking change,schema incomplete"
-
- -

breaking change covers a removed parameter, a parameter that became required, a parameter -emitted under a different API key, a changed base class, and a changed -api_group/api_version. schema incomplete marks a class rendered without any -parameters because a schema $ref could not be resolved — fix the schema, not the file.

-

See the class-generator CLI reference for every -option and classification.

-

Discovering Missing Resources

-

Find resources on your cluster that don't have wrapper classes yet:

-
class-generator --discover-missing
-
- -

Coverage report

-

Get a summary of how many resources have wrapper classes:

-
class-generator --coverage-report
-
- -

Output in JSON for CI/CD pipelines:

-
class-generator --coverage-report --json
-
- -

Auto-generate all missing resources

-

Update schemas and generate classes for every resource that's missing:

-
class-generator --update-schema --generate-missing
-
- -
-

Tip: Use --dry-run with --generate-missing to preview what would be generated without writing any files.

-
-

Advanced Usage

-

Batch regeneration

-

Regenerate all existing generated resource classes to pick up schema changes:

-
class-generator --regenerate-all
-
- -

With backups:

-
class-generator --regenerate-all --backup
-
- -

Filter to a subset of resources using glob patterns:

-
class-generator --regenerate-all --filter "Pod*"
-class-generator --regenerate-all --filter "*Service"
-
- -

Adding tests

-

Generate test files alongside the resource class:

-
class-generator -k Pod --add-tests
-
- -

This creates test manifests and runs them automatically with pytest. Tests are placed under the test manifests directory and validate that the generated class can be parsed correctly.

-

Shell completion

-

Add this to your ~/.bashrc or ~/.zshrc for tab completion:

-
# For zsh
-if type class-generator > /dev/null; then eval "$(_CLASS_GENERATOR_COMPLETE=zsh_source class-generator)"; fi
-
-# For bash
-if type class-generator > /dev/null; then eval "$(_CLASS_GENERATOR_COMPLETE=bash_source class-generator)"; fi
-
- -

Verbose output

-

Enable debug logging to see exactly what the generator is doing:

-
class-generator -k Deployment -v
-
- -

When a kind is missing from the schema

-

If the kind you request is not in the local schema cache, the CLI prompts you:

-
deployment not found in schema mapping, Do you want to run --update-schema and retry? [Y/N]:
-
- -

Answering y triggers a schema update and retries generation automatically.

-
-

Note: The prompt only appears in interactive mode. When generating multiple kinds or running in batch mode, missing kinds are skipped with a warning. Run --update-schema separately beforehand in CI environments.

-
-

API group and version warnings

-

If a generated resource uses an API group or version that hasn't been registered in the base Resource class, the generator logs a warning like:

-
Missing API Group in Resource
-Please add `Resource.ApiGroup.MY_GROUP_IO = my-group.io` manually into
-ocp_resources/resource.py under Resource class > ApiGroup class.
-
- -

Follow the instructions to register the API group so the resource can connect to the correct API endpoint. See Understanding the Resource Class Hierarchy for details on how ApiGroup and ApiVersion are used.

-

CLI Options Reference

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OptionDescription
-k, --kindKind(s) to generate (comma-separated)
-o, --output-fileCustom output file path
--overwriteOverwrite existing files
--dry-runPreview output without writing files
--add-testsGenerate test files for the kind
--update-schemaUpdate all schema files from cluster
--update-schema-forUpdate schema for a single kind
--discover-missingFind resources without wrapper classes
--coverage-reportShow coverage statistics
--jsonOutput reports in JSON format
--generate-missingGenerate classes for all missing resources
--regenerate-allRegenerate all existing generated classes
--backupCreate backup before overwriting/regenerating
--filterGlob pattern to filter --regenerate-all
-v, --verboseEnable debug logging
-

For the full CLI reference, see class-generator CLI Reference.

-

Troubleshooting

-

"Neither 'oc' nor 'kubectl' binary found in PATH" -Install oc from the OpenShift mirror or kubectl from the Kubernetes docs. Ensure the binary is on your PATH.

-

"Resource kind 'X' not found" -The kind is not in the local schema cache. Run class-generator --update-schema while connected to a cluster that has the CRD installed, then retry.

-

Generated filename looks wrong (e.g., c_d_i_config.py) -Single-letter segments in snake_case names indicate the camelCase-to-snake_case converter doesn't recognize an acronym. The generator raises an error with instructions to add the acronym to the converter.

-

"Failed to fetch OpenAPI v3 index" -Ensure you are logged in to the cluster with sufficient privileges. The generator needs to access the /openapi/v3 endpoint, which typically requires cluster-admin access.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index be7f165d65..0000000000 --- a/docs/index.html +++ /dev/null @@ -1,491 +0,0 @@ - - - - - - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-

openshift-python-wrapper

-

Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls

- - - - - - - - - - - - - - Get Started → - -
- -
- - - - - - - - - -
- -
-
🤖
-
- AI-friendly documentation -

This documentation is optimized for AI consumption.

- -
-
- -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/llms-full.txt b/docs/llms-full.txt deleted file mode 100644 index a1e27b4fdf..0000000000 --- a/docs/llms-full.txt +++ /dev/null @@ -1,10319 +0,0 @@ -# openshift-python-wrapper - -> Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls - ---- - -Source: quickstart.md - -# Installing and Creating Your First Resource - -Get up and running with openshift-python-wrapper so you can programmatically create, query, and delete Kubernetes and OpenShift resources from Python. This guide takes you from installation to your first working resource in under 5 minutes. - -## Prerequisites - -- Python 3.11-3.14 -- Access to a Kubernetes or OpenShift cluster (with a valid kubeconfig) -- `pip` or another Python package manager - -## Quick Example - -```bash - pip install openshift-python-wrapper -``` - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - with Namespace(client=client, name="my-first-namespace") as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) - print(f"Namespace {ns.name} is active: {ns.exists}") - # Namespace is automatically deleted when the block exits -``` - -That's it — the namespace is created when entering the `with` block and cleaned up when leaving it. - -## Step 1: Install the Package - -```bash - pip install openshift-python-wrapper -``` - -> **Tip:** For isolated environments, use a virtual environment: -> ```bash -> python -m venv .venv && source .venv/bin/activate -> pip install openshift-python-wrapper -> ``` - -## Step 2: Connect to Your Cluster - -The `get_client()` function returns a dynamic client connected to your cluster. By default it reads your kubeconfig from the `KUBECONFIG` environment variable or `~/.kube/config`: - -```python - from ocp_resources.resource import get_client - - client = get_client() -``` - -You can also point to a specific kubeconfig file: - -```python - client = get_client(config_file="/path/to/kubeconfig") -``` - -Or connect with a token and host directly: - -```python - client = get_client(host="https://api.mycluster.example.com:6443", token="sha256~...") -``` - -> **Note:** For the full set of connection options — including basic auth, in-cluster config, and environment variables — see [Connecting to Clusters](connecting-to-clusters.html). - -## Step 3: Create a Resource - -There are two ways to create a resource: explicitly with `deploy()`/`clean_up()`, or automatically with a context manager (`with` statement). - -### Option A: Context Manager (Recommended) - -The context manager automatically deletes the resource when the block exits, which prevents leftover resources: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - with Namespace(client=client, name="namespace-example-2") as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) - assert ns.exists - # Resource is automatically cleaned up here -``` - -### Option B: Explicit Deploy and Clean Up - -Use `deploy()` and `clean_up()` when you need more control over the lifecycle: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - ns = Namespace(client=client, name="namespace-example-1") - ns.deploy() - assert ns.exists - ns.clean_up() -``` - -> **Tip:** Set `teardown=False` on the resource to prevent `clean_up()` from being called when using a context manager. This is useful when you want the resource to persist after the block exits. - -## Step 4: Create a Namespaced Resource - -Most Kubernetes resources (Pods, ConfigMaps, Deployments, etc.) live inside a namespace. These require both `name` and `namespace`: - -```python - from ocp_resources.config_map import ConfigMap - from ocp_resources.resource import get_client - - client = get_client() - - with ConfigMap( - client=client, - name="app-config", - namespace="default", - data={"app.properties": "debug=true\nport=8080"}, - ) as cm: - assert cm.exists - print(f"ConfigMap {cm.name} created in namespace {cm.namespace}") -``` - -## Step 5: Query Resources - -Use the `get()` class method to list resources from the cluster. It returns a generator you can iterate over: - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - - for pod in Pod.get(client=client, namespace="default"): - print(pod.name) -``` - -Filter by labels using `label_selector`: - -```python - for pod in Pod.get(client=client, label_selector="app=nginx"): - node = pod.node - print(f"Pod {pod.name} is running on node {node.name}") -``` - -Check if a specific resource exists: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - ns = Namespace(client=client, name="default") - if ns.exists: - print("Namespace exists!") -``` - -> **Note:** For more on querying, filtering, and watching resources, see [Querying and Watching Resources](querying-resources.html). - -## Step 6: Delete a Resource - -If you didn't use a context manager, call `clean_up()` to delete the resource: - -```python - ns = Namespace(client=client, name="my-temp-namespace") - ns.deploy() - # ... do work ... - ns.clean_up() -``` - -The `clean_up()` method waits for the resource to be fully deleted by default. Pass `wait=False` to return immediately: - -```python - ns.clean_up(wait=False) -``` - -> **Note:** For full details on resource lifecycle management, see [Creating and Managing Resources](creating-and-managing-resources.html). - -## Putting It All Together - -Here's a complete script that creates a namespace, deploys a ConfigMap inside it, reads it back, and cleans everything up: - -```python - from ocp_resources.config_map import ConfigMap - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - with Namespace(client=client, name="quickstart-demo") as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) - - with ConfigMap( - client=client, - name="demo-config", - namespace="quickstart-demo", - data={"greeting": "hello from openshift-python-wrapper"}, - ) as cm: - assert cm.exists - print(f"Created ConfigMap '{cm.name}' in namespace '{ns.name}'") - - # Query it back - for config_map in ConfigMap.get(client=client, namespace="quickstart-demo"): - print(f" Found: {config_map.name}") - # ConfigMap cleaned up here - # Namespace cleaned up here -``` - -## Advanced Usage - -### Creating Resources from YAML - -You can create any resource from an existing YAML file instead of specifying parameters in Python: - -```python - from ocp_resources.config_map import ConfigMap - from ocp_resources.resource import get_client - - client = get_client() - - cm = ConfigMap(client=client, yaml_file="my-configmap.yaml") - cm.deploy() -``` - -### Using a Fake Client for Testing - -You can run your code without a live cluster by using a fake client — useful for unit tests and local development: - -```python - from ocp_resources.config_map import ConfigMap - from ocp_resources.resource import get_client - - client = get_client(fake=True) - - with ConfigMap( - client=client, - name="test-config", - namespace="default", - data={"key": "value"}, - ) as cm: - cm.create() - print(f"Created (fake): {cm.name}") -``` - -> **Note:** See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for full details on the fake client. - -### Schema Validation Before Creating - -Enable schema validation to catch configuration errors before sending requests to the cluster: - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - - pod = Pod( - client=client, - name="validated-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - schema_validation_enabled=True, # Validates on create - ) - pod.deploy() -``` - -You can also validate manually without creating: - -```python - pod = Pod( - name="check-me", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - pod.validate() # Raises ValidationError if invalid -``` - -> **Note:** See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for more validation options. - -### Waiting for Resource Conditions - -After creating a resource, you can wait for it to reach a specific status or condition: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - with Namespace(client=client, name="wait-demo") as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) -``` - -> **Note:** See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for advanced waiting patterns. - -## Troubleshooting - -### "KUBECONFIG not found" or connection errors - -Make sure your kubeconfig is in the expected location or explicitly set the path: - -```bash - export KUBECONFIG=/path/to/kubeconfig -``` - -Or pass it directly: - -```python - client = get_client(config_file="/path/to/kubeconfig") -``` - -### Resource not found after creation - -Some resources take time to become available. Use `wait_for_status()` or `wait()` to confirm the resource is ready before interacting with it: - -```python - ns.deploy() - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) -``` - -### Missing `name` or `namespace` errors - -- Cluster-scoped resources (like `Namespace`) require only `name`. -- Namespaced resources (like `Pod`, `ConfigMap`, `Deployment`) require both `name` and `namespace`. - -```python - # Cluster-scoped — only name needed - Namespace(client=client, name="my-ns") - - # Namespaced — both name and namespace required - ConfigMap(client=client, name="my-cm", namespace="my-ns") -``` - -## Related Pages - -- [Connecting to Clusters](connecting-to-clusters.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Querying and Watching Resources](querying-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) - - ---- - -Source: connecting-to-clusters.md - -# Connecting to Clusters - -Establish authenticated connections to Kubernetes and OpenShift clusters so you can create, query, and manage resources with openshift-python-wrapper. - -## Prerequisites - -- openshift-python-wrapper installed (see [Installing and Creating Your First Resource](quickstart.html)) -- Access to a Kubernetes or OpenShift cluster (kubeconfig file, token, or in-cluster credentials) - -## Quick Example - -```python - from ocp_resources.resource import get_client - from ocp_resources.namespace import Namespace - - # Connect using default kubeconfig (~/.kube/config or $KUBECONFIG) - client = get_client() - - # Use the client with any resource - for ns in Namespace.get(client=client): - print(ns.name) -``` - -## Creating a Client - -All cluster connections go through `get_client()`, which returns a Kubernetes `DynamicClient`. Pass this client to every resource you create or query. - -### Using the Default Kubeconfig - -If you have a kubeconfig at `~/.kube/config` or the `KUBECONFIG` environment variable is set, no arguments are needed: - -```python - from ocp_resources.resource import get_client - - client = get_client() -``` - -The lookup order is: - -1. The `config_file` parameter (if provided) -2. The `KUBECONFIG` environment variable -3. `~/.kube/config` - -### Using a Specific Kubeconfig File - -```python - client = get_client(config_file="/path/to/my/kubeconfig") -``` - -### Selecting a Context - -If your kubeconfig has multiple contexts, select one by name: - -```python - client = get_client(config_file="/path/to/kubeconfig", context="my-staging-cluster") -``` - -### Authenticating with a Bearer Token - -Connect to a cluster API server directly using a host URL and token: - -```python - client = get_client( - host="https://api.my-cluster.example.com:6443", - token="sha256~my-bearer-token", - ) -``` - -### Authenticating with Username and Password (Basic Auth) - -For OpenShift clusters that support OAuth-based basic authentication: - -```python - client = get_client( - host="https://api.my-cluster.example.com:6443", - username="my-user", - password="my-password", - ) -``` - -> **Note:** Basic auth requires all three parameters: `host`, `username`, and `password`. This uses OpenShift's OAuth flow internally — it is not plain HTTP Basic Authentication. - -### Using a Kubeconfig Dictionary - -Pass a kubeconfig as a Python dictionary instead of a file path: - -```python - config_dict = { - "apiVersion": "v1", - "kind": "Config", - "clusters": [{"name": "my-cluster", "cluster": {"server": "https://api.example.com:6443"}}], - "users": [{"name": "my-user", "user": {"token": "my-token"}}], - "contexts": [{"name": "my-context", "context": {"cluster": "my-cluster", "user": "my-user"}}], - "current-context": "my-context", - } - - client = get_client(config_dict=config_dict) -``` - -## Passing the Client to Resources - -Once you have a client, pass it to resource constructors and class methods: - -```python - from ocp_resources.resource import get_client - from ocp_resources.namespace import Namespace - from ocp_resources.pod import Pod - - client = get_client() - - # Creating a resource - ns = Namespace(client=client, name="my-namespace") - ns.deploy() - - # Querying resources - for pod in Pod.get(client=client, namespace="my-namespace"): - print(pod.name) - - # Using context managers - with Namespace(client=client, name="temp-namespace") as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) -``` - -> **Warning:** The `client` parameter will become mandatory in the next major release. Always pass it explicitly. If omitted, the library creates a client automatically using the default kubeconfig, but this triggers a `FutureWarning`. - -## Environment Variables - -`get_client()` respects several environment variables automatically: - -| Variable | Purpose | Default | -|---|---|---| -| `KUBECONFIG` | Path to the kubeconfig file | `~/.kube/config` | -| `HTTPS_PROXY` | HTTPS proxy for cluster connections | None | -| `HTTP_PROXY` | HTTP proxy (used if `HTTPS_PROXY` is not set) | None | - -> **Tip:** `HTTPS_PROXY` takes precedence over `HTTP_PROXY` when both are set. - -For other environment variables that control logging, resource reuse, and teardown behavior, see [Environment Variables and Configuration](environment-variables.html). - -## Advanced Usage - -### Disabling TLS Verification - -For development clusters with self-signed certificates: - -```python - client = get_client( - host="https://api.dev-cluster.example.com:6443", - token="sha256~my-token", - verify_ssl=False, - ) -``` - -> **Warning:** Never disable TLS verification in production environments. - -### In-Cluster Configuration - -When your code runs inside a pod on the cluster (for example, in a CI/CD pipeline or operator), `get_client()` automatically falls back to in-cluster configuration if the kubeconfig-based connection fails: - -```python - # Inside a pod — no config_file or host needed - client = get_client() -``` - -The library first attempts to connect using the kubeconfig. If that fails with a connection error, it loads credentials from the pod's service account token mounted at `/var/run/secrets/kubernetes.io/serviceaccount/`. - -### Generating a Kubeconfig File from a Token Connection - -When you connect with `host` and `token`, you might need a kubeconfig file on disk (for example, to pass to external tools). Use `generate_kubeconfig=True`: - -```python - client = get_client( - host="https://api.my-cluster.example.com:6443", - token="sha256~my-token", - generate_kubeconfig=True, - ) - - # Access the generated kubeconfig path - print(client.kubeconfig) -``` - -The generated kubeconfig file: -- Is written to a temporary file with `0o600` permissions -- Is automatically cleaned up when the process exits - -If you already passed a `config_file`, the `generate_kubeconfig` flag reuses that file path instead of creating a new one. - -### Passing a Custom Client Configuration - -For fine-grained control over TLS settings, timeouts, or proxy configuration, pass a `kubernetes.client.Configuration` object: - -```python - import kubernetes - from ocp_resources.resource import get_client - - config = kubernetes.client.Configuration() - config.verify_ssl = False - config.proxy = "http://my-proxy:8080" - - client = get_client( - host="https://api.my-cluster.example.com:6443", - token="sha256~my-token", - client_configuration=config, - ) -``` - -### Connecting to Multiple Clusters - -Create separate clients for each cluster and pass them to different resources: - -```python - from ocp_resources.resource import get_client - from ocp_resources.namespace import Namespace - - prod_client = get_client(config_file="/path/to/prod-kubeconfig") - staging_client = get_client(config_file="/path/to/staging-kubeconfig") - - # Query namespaces on both clusters - prod_namespaces = list(Namespace.get(client=prod_client)) - staging_namespaces = list(Namespace.get(client=staging_client)) -``` - -### Using the Fake Client for Testing - -For unit tests that don't need a real cluster, pass `fake=True`: - -```python - client = get_client(fake=True) -``` - -This returns a fake client that simulates Kubernetes API operations in memory. See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for full details. - -## `get_client()` API Reference - -```python - from ocp_resources.resource import get_client - - get_client( - config_file: str | None = None, - config_dict: dict | None = None, - context: str | None = None, - client_configuration: kubernetes.client.Configuration | None = None, - persist_config: bool = True, - temp_file_path: str | None = None, - try_refresh_token: bool = True, - username: str | None = None, - password: str | None = None, - host: str | None = None, - verify_ssl: bool | None = None, - token: str | None = None, - fake: bool = False, - generate_kubeconfig: bool = False, - ) -> DynamicClient -``` - -| Parameter | Type | Description | -|---|---|---| -| `config_file` | `str \| None` | Path to a kubeconfig file | -| `config_dict` | `dict \| None` | Kubeconfig as a Python dictionary | -| `context` | `str \| None` | Name of the kubeconfig context to use | -| `client_configuration` | `Configuration \| None` | Custom `kubernetes.client.Configuration` object | -| `persist_config` | `bool` | Whether to persist config changes back to the kubeconfig file | -| `temp_file_path` | `str \| None` | Path for temporary kubeconfig file (used with `config_dict`) | -| `try_refresh_token` | `bool` | Attempt to refresh the authentication token (for in-cluster config) | -| `username` | `str \| None` | Username for basic authentication | -| `password` | `str \| None` | Password for basic authentication | -| `host` | `str \| None` | Cluster API server URL (e.g., `https://api.example.com:6443`) | -| `verify_ssl` | `bool \| None` | Set to `False` to skip TLS certificate verification | -| `token` | `str \| None` | Bearer token for authentication | -| `fake` | `bool` | Return a fake in-memory client for testing | -| `generate_kubeconfig` | `bool` | Save connection info to a temporary kubeconfig file accessible via `client.kubeconfig` | - -**Returns:** `DynamicClient` — a Kubernetes dynamic client ready for resource operations. - -**Raises:** -- `kubernetes.config.ConfigException` — if no valid kubeconfig is found and in-cluster config is unavailable -- `urllib3.exceptions.MaxRetryError` — if the cluster is unreachable (before falling back to in-cluster config) -- `ClientWithBasicAuthError` — if basic auth (username/password) fails - -## Authentication Method Priority - -When multiple authentication parameters are provided, `get_client()` uses this priority order: - -1. **Basic auth** — if `username`, `password`, and `host` are all provided -2. **Token auth** — if `host` and `token` are provided -3. **Config dict** — if `config_dict` is provided -4. **Kubeconfig file** — uses `config_file`, `KUBECONFIG` env var, or `~/.kube/config` -5. **In-cluster config** — automatic fallback if the kubeconfig connection fails - -## Troubleshooting - -**"FutureWarning: 'client' arg will be mandatory"** -You are creating a resource without passing a `client`. Always call `get_client()` first and pass the result: - -```python - client = get_client() - ns = Namespace(client=client, name="my-ns") # Pass client explicitly -``` - -**Connection refused or timeout errors** -Verify your cluster is reachable and the kubeconfig or token is correct. Check that `HTTPS_PROXY` / `HTTP_PROXY` environment variables are set correctly if you are behind a proxy. - -**"KUBECONFIG environment variable set in code is not detected"** -The Kubernetes Python client reads `KUBECONFIG` at module import time. If you set it dynamically in your code, pass the path directly instead: - -```python - import os - - # Don't rely on: os.environ["KUBECONFIG"] = "/path/to/config" - # Instead, pass it explicitly: - client = get_client(config_file=os.environ.get("KUBECONFIG", "/path/to/config")) -``` - -**Basic auth fails with `ClientWithBasicAuthError`** -Basic auth uses the OpenShift OAuth flow. Ensure your cluster has a well-known OAuth endpoint at `/.well-known/oauth-authorization-server`. This method does not work with vanilla Kubernetes clusters. - -## Related Pages - -- [Installing and Creating Your First Resource](quickstart.html) -- [Environment Variables and Configuration](environment-variables.html) -- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Error Handling and Exception Reference](error-handling.html) - - ---- - -Source: creating-and-managing-resources.md - -# Creating and Managing Resources - -Deploy Kubernetes and OpenShift resources to your cluster, update them in place, and clean them up reliably using the openshift-python-wrapper's resource lifecycle methods. - -## Prerequisites - -- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html)) -- A connected client — either a real cluster connection or a fake client for testing (see [Connecting to Clusters](connecting-to-clusters.html)) - -## Quick Example - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - - # Create, use, and automatically delete a Namespace - with Namespace(client=client, name="my-app") as ns: - print(f"{ns.name} is ready") - # Namespace is deleted when the block exits -``` - -## Creating Resources - -Every resource provides three ways to create it: calling `deploy()` directly, using a context manager, or loading from a YAML file. - -### Using `deploy()` and `clean_up()` - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="my-namespace") - ns.deploy() - - # ... do work ... - - ns.clean_up() # Deletes the resource and waits for deletion -``` - -`deploy()` returns the resource instance, so you can chain: - -```python - ns = Namespace(client=client, name="my-namespace").deploy() -``` - -Pass `wait=True` to `deploy()` to block until the resource reaches its ready state: - -```python - ns = Namespace(client=client, name="my-namespace") - ns.deploy(wait=True) -``` - -### Using a Context Manager - -The context manager calls `deploy()` on entry and `clean_up()` on exit, guaranteeing cleanup even if an exception occurs: - -```python - from ocp_resources.config_map import ConfigMap - - with ConfigMap( - client=client, - name="app-config", - namespace="my-namespace", - data={"database_url": "postgres://db:5432/myapp"}, - ) as cm: - print(cm.instance.data) - # ConfigMap is automatically deleted here -``` - -> **Note:** If cleanup fails during context manager exit, a `ResourceTeardownError` is raised so failures are never silently ignored. - -### Namespaced vs. Cluster-Scoped Resources - -Resources that live within a namespace inherit from `NamespacedResource` and require both `name` and `namespace`: - -```python - from ocp_resources.pod import Pod - - pod = Pod( - client=client, - name="nginx", - namespace="my-namespace", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - pod.deploy() -``` - -Cluster-scoped resources (like `Namespace`) inherit from `Resource` and need only `name`: - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="my-namespace") - ns.deploy() -``` - -### Creating Resources from a YAML File - -Pass `yaml_file` to create any resource directly from a YAML manifest. The resource name and namespace are read from the file: - -```python - from ocp_resources.config_map import ConfigMap - - cm = ConfigMap(client=client, yaml_file="manifests/configmap.yaml") - cm.deploy() -``` - -`yaml_file` also accepts a `StringIO` object for in-memory YAML: - -```python - from io import StringIO - from ocp_resources.config_map import ConfigMap - - yaml_content = StringIO(""" - apiVersion: v1 - kind: ConfigMap - metadata: - name: dynamic-config - namespace: my-namespace - data: - setting: "enabled" - """) - - cm = ConfigMap(client=client, yaml_file=yaml_content) - cm.deploy() -``` - -### Creating Resources from a Dictionary - -Use `kind_dict` to pass a pre-built dictionary. When `kind_dict` is provided, the resource class's `to_dict()` logic is bypassed entirely: - -```python - from ocp_resources.config_map import ConfigMap - - resource_dict = { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": "from-dict", - "namespace": "my-namespace", - }, - "data": {"key": "value"}, - } - - cm = ConfigMap(client=client, kind_dict=resource_dict) - cm.deploy() -``` - -> **Warning:** `yaml_file` and `kind_dict` are mutually exclusive. Passing both raises a `ValueError`. - -### Adding Labels and Annotations - -Pass `label` and `annotations` at construction time: - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace( - client=client, - name="labeled-ns", - label={"team": "platform", "env": "staging"}, - annotations={"description": "Staging environment namespace"}, - ) - ns.deploy() -``` - -## Updating Resources - -### Patching with `update()` - -`update()` sends a merge patch — it adds or modifies fields without removing existing ones: - -```python - cm = ConfigMap( - client=client, - name="app-config", - namespace="my-namespace", - ensure_exists=True, - ) - - cm.update( - resource_dict={ - "data": {"new_key": "new_value"}, - } - ) -``` - -### Replacing with `update_replace()` - -`update_replace()` performs a full replacement of the resource. Use this when you need to **remove** fields that `update()` would leave untouched: - -```python - # Get the current state - current = cm.instance.to_dict() - - # Remove a key - current["data"].pop("old_key", None) - - # Replace the entire resource - cm.update_replace(resource_dict=current) -``` - -| Method | HTTP Verb | Behavior | Use When | -|--------|-----------|----------|----------| -| `update()` | PATCH | Merges fields into existing resource | Adding or changing fields | -| `update_replace()` | PUT | Replaces entire resource | Removing fields or doing full replacements | - -## Deleting Resources - -### Using `clean_up()` - -```python - ns.clean_up() # Deletes and waits for removal (default: wait=True) -``` - -Control the deletion behavior: - -```python - # Delete without waiting - ns.clean_up(wait=False) - - # Delete with a custom timeout (in seconds) - ns.clean_up(timeout=120) -``` - -`clean_up()` returns `True` if the resource was successfully deleted, `False` otherwise. - -### Using `delete()` - -`delete()` is the lower-level method called by `clean_up()`: - -```python - ns.delete(wait=True, timeout=240) -``` - -You can also pass a custom body for delete options: - -```python - ns.delete(body={"propagationPolicy": "Background"}) -``` - -> **Tip:** If you delete a resource that doesn't exist, `delete()` logs a warning and returns `True` instead of raising an error. - -### Controlling Teardown - -Set `teardown=False` at construction to prevent automatic deletion in context managers: - -```python - with Namespace(client=client, name="persistent-ns", teardown=False) as ns: - print("This namespace will NOT be deleted on exit") -``` - -## Checking Resource State - -### Check If a Resource Exists - -```python - ns = Namespace(client=client, name="my-namespace") - if ns.exists: - print("Namespace exists") -``` - -### Ensure a Resource Already Exists - -Use `ensure_exists=True` to raise `ResourceNotFoundError` if the resource is not present on the cluster: - -```python - from ocp_resources.config_map import ConfigMap - - # Raises ResourceNotFoundError if the ConfigMap doesn't exist - cm = ConfigMap( - client=client, - name="must-exist", - namespace="default", - ensure_exists=True, - ) -``` - -### Get the Live Instance - -The `instance` property fetches the current state from the API server: - -```python - ns = Namespace(client=client, name="my-namespace", ensure_exists=True) - print(ns.instance.metadata.labels) - print(ns.status) -``` - -### Export as YAML - -```python - ns = Namespace(client=client, name="my-namespace") - print(ns.to_yaml()) -``` - -For more on querying, watching, and status checks, see [Querying and Watching Resources](querying-resources.html). - -## Dry Run Mode - -Validate a resource against the API server without actually creating it: - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="dry-run-ns", dry_run=True) - ns.deploy() # Sends the request with ?dryRun=All — no resource is created -``` - -## Advanced Usage - -### Constructor Parameters Reference - -All resource classes accept these common parameters: - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `client` | `DynamicClient` | `None` | Cluster client connection (will be required in next major release) | -| `name` | `str` | `None` | Resource name | -| `namespace` | `str` | `None` | Namespace (namespaced resources only) | -| `teardown` | `bool` | `True` | Whether `clean_up()` deletes the resource | -| `yaml_file` | `str` | `None` | Path to a YAML manifest file | -| `kind_dict` | `dict` | `None` | Dictionary representation of the resource | -| `delete_timeout` | `int` | `240` | Timeout in seconds for delete operations | -| `dry_run` | `bool` | `False` | Server-side dry run without persisting | -| `label` | `dict` | `None` | Labels to set on the resource | -| `annotations` | `dict` | `None` | Annotations to set on the resource | -| `ensure_exists` | `bool` | `False` | Raise if resource doesn't already exist | -| `wait_for_resource` | `bool` | `False` | Wait for resource creation in context manager | -| `schema_validation_enabled` | `bool` | `False` | Validate against OpenAPI schema on create/replace | -| `hash_log_data` | `bool` | `True` | Mask sensitive data in logs | - -For the full API reference, see [Resource and NamespacedResource API](resource-api.html). - -### Schema Validation on Create and Replace - -Enable automatic schema validation so `create()` and `update_replace()` check your resource against its OpenAPI schema before sending it to the API server: - -```python - from ocp_resources.config_map import ConfigMap - - cm = ConfigMap( - client=client, - name="validated-cm", - namespace="default", - data={"key": "value"}, - schema_validation_enabled=True, # Validates before create() - ) - cm.deploy() -``` - -You can also validate on demand or validate a dictionary directly: - -```python - # Validate an existing resource instance - cm.validate() - - # Validate a dictionary without creating a resource - ConfigMap.validate_dict({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": {"name": "test"}, - "data": {"key": "value"}, - }) -``` - -Both methods raise `ValidationError` if validation fails. For more details, see [Validating Resources Against OpenAPI Schemas](validating-resources.html). - -### Temporary Edits with ResourceEditor - -`ResourceEditor` applies patches to existing resources and automatically restores original values when used as a context manager: - -```python - from ocp_resources.resource import ResourceEditor - - ns = Namespace(client=client, name="my-namespace", ensure_exists=True) - - with ResourceEditor(patches={ns: {"metadata": {"labels": {"temporary-label": "true"}}}}): - # Label is applied - assert ns.labels["temporary-label"] == "true" - # Label is automatically removed -``` - -`ResourceEditor` supports both `update` (merge patch) and `replace` actions: - -```python - with ResourceEditor( - patches={ns: {"metadata": {"labels": {"keep-only-this": "true"}}}}, - action="replace", - ): - pass # Full replacement applied, then restored on exit -``` - -For complete coverage, see [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html). - -### Managing Multiple Resources - -#### ResourceList — Create N Copies - -Create multiple instances of the same resource type with indexed names: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import ResourceList - - with ResourceList( - client=client, - resource_class=Namespace, - name="test-ns", - num_resources=3, - ) as namespaces: - # Creates test-ns-1, test-ns-2, test-ns-3 - for ns in namespaces: - print(ns.name) - # All three namespaces are deleted on exit -``` - -#### NamespacedResourceList — One Per Namespace - -Create one resource in each namespace from a `ResourceList`: - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import NamespacedResourceList, ResourceList - - namespaces = ResourceList( - client=client, - resource_class=Namespace, - name="env", - num_resources=3, - ) - - with NamespacedResourceList( - client=client, - resource_class=Pod, - namespaces=namespaces, - name="worker", - containers=[{"name": "app", "image": "myapp:latest"}], - ) as pods: - for pod in pods: - print(f"{pod.name} in {pod.namespace}") -``` - -For detailed patterns, see [Managing Bulk Resources with ResourceList](managing-resource-lists.html). - -### Debugging with Environment Variables - -These environment variables control resource lifecycle behavior at runtime without code changes: - -| Variable | Effect | -|----------|--------| -| `REUSE_IF_RESOURCE_EXISTS` | Skip `deploy()` if the resource already exists | -| `SKIP_RESOURCE_TEARDOWN` | Skip `clean_up()` to keep resources for debugging | - -Both accept a YAML-formatted dictionary. Spaces are significant: - -```bash - # Skip creation of all Pods - export REUSE_IF_RESOURCE_EXISTS="{Pod: {}}" - - # Skip creation of a specific Pod in a specific namespace - export REUSE_IF_RESOURCE_EXISTS="{Pod: {my-pod: my-namespace}}" - - # Skip teardown for a Namespace and a Pod - export SKIP_RESOURCE_TEARDOWN="{Namespace: {my-ns:}, Pod: {my-pod: my-namespace}}" -``` - -For the full list of environment variables, see [Environment Variables and Configuration](environment-variables.html). - -## Troubleshooting - -**`MissingRequiredArgumentError` on construction** -You must provide either `name`, `yaml_file`, or `kind_dict`. For namespaced resources, both `name` and `namespace` are required unless using `yaml_file` or `kind_dict`. - -**`ResourceTeardownError` in context manager** -The resource's `clean_up()` returned `False`, meaning deletion failed. Check cluster permissions and resource finalizers. - -**`ValueError: yaml_file and resource_dict are mutually exclusive`** -You passed both `yaml_file` and `kind_dict` to a constructor. Use only one. - -**`ResourceNotFoundError` with `ensure_exists=True`** -The resource doesn't exist on the cluster. Verify the name (and namespace for namespaced resources) are correct. - -**Resource not deleted after context manager exits** -Check if `teardown=False` was passed. Also verify the `SKIP_RESOURCE_TEARDOWN` environment variable is not set. - -## Related Pages - -- [Installing and Creating Your First Resource](quickstart.html) -- [Querying and Watching Resources](querying-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html) -- [Resource and NamespacedResource API](resource-api.html) - - ---- - -Source: querying-resources.md - -# Querying and Watching Resources - -Find, filter, and monitor Kubernetes and OpenShift resources in your cluster — whether you need to list all pods by label, check if a resource exists, watch for real-time changes, or inspect status and conditions. - -## Prerequisites - -- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html)) -- A connected `DynamicClient` instance (see [Connecting to Clusters](connecting-to-clusters.html)) - -## Quick Example - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - - # List all pods in a namespace - for pod in Pod.get(client=client, namespace="my-namespace"): - print(f"{pod.name} — {pod.status}") -``` - -## Listing Resources - -Every resource class provides a `get()` class method that returns a generator of resource objects. The method accepts standard Kubernetes query parameters as keyword arguments. - -### List all resources of a type - -```python - from ocp_resources.namespace import Namespace - - for ns in Namespace.get(client=client): - print(ns.name) -``` - -### Filter by namespace - -Namespaced resources (like `Pod`, `Service`, `ConfigMap`) accept a `namespace` keyword: - -```python - from ocp_resources.pod import Pod - - for pod in Pod.get(client=client, namespace="default"): - print(pod.name) -``` - -### Filter by label selector - -Use `label_selector` with standard Kubernetes label selector syntax: - -```python - from ocp_resources.pod import Pod - - # Single label - for pod in Pod.get(client=client, namespace="my-app", label_selector="app=nginx"): - print(pod.name) - - # Multiple labels (comma-separated) - for pod in Pod.get(client=client, label_selector="app=nginx,tier=frontend"): - print(pod.name) -``` - -### Filter by field selector - -Use `field_selector` with standard Kubernetes field selector syntax: - -```python - from ocp_resources.pod import Pod - - # Find pods on a specific node - for pod in Pod.get(client=client, field_selector="spec.nodeName=worker-1"): - print(pod.name) - - # Find pods by status phase - for pod in Pod.get(client=client, namespace="default", field_selector="status.phase=Running"): - print(pod.name) -``` - -### Get raw resource objects - -By default, `get()` returns wrapper objects. Pass `raw=True` to get the underlying `ResourceInstance` or `ResourceField` objects directly: - -```python - from ocp_resources.pod import Pod - - for pod in Pod.get(client=client, namespace="default", raw=True): - # Returns raw ResourceField objects instead of Pod instances - print(pod.metadata.name, pod.status.phase) -``` - -### List all cluster resources - -To iterate over every resource type in the cluster: - -```python - from ocp_resources.resource import Resource - - for resource in Resource.get_all_cluster_resources(client=client): - print(f"{resource.kind}: {resource.metadata.name}") - - # Filter with label selector - for resource in Resource.get_all_cluster_resources(client=client, label_selector="my-label=value"): - print(f"{resource.kind}: {resource.metadata.name}") -``` - -## `get()` Method Reference - -```python - @classmethod - def get( - cls, - client: DynamicClient | None = None, - singular_name: str = "", - exceptions_dict: dict[type[Exception], list[str]] = DEFAULT_CLUSTER_RETRY_EXCEPTIONS, - raw: bool = False, - *args, - **kwargs, - ) -> Generator -``` - -| Parameter | Type | Description | -|---|---|---| -| `client` | `DynamicClient` | Kubernetes dynamic client (required) | -| `singular_name` | `str` | Resource kind in lowercase; used when multiple resources match the same kind | -| `raw` | `bool` | If `True`, return raw `ResourceInstance`/`ResourceField` objects instead of wrapper instances | -| `exceptions_dict` | `dict` | Exceptions to retry on during API calls | -| `**kwargs` | | Passed through to the Kubernetes API: `namespace`, `label_selector`, `field_selector`, `limit`, `resource_version`, `timeout_seconds`, etc. | - -**Returns:** A generator yielding resource objects. - -## Checking Resource Existence - -The `exists` property queries the API server and returns the resource instance if it exists, or `None` if it does not. - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="my-namespace") - - if ns.exists: - print("Namespace exists") - else: - print("Namespace not found") -``` - -### Require existence at initialization - -Use `ensure_exists=True` to raise `ResourceNotFoundError` immediately if the resource is not found: - -```python - from ocp_resources.pod import Pod - - # Raises ResourceNotFoundError if the pod doesn't exist - pod = Pod(client=client, name="my-pod", namespace="default", ensure_exists=True) -``` - -> **Tip:** Use `ensure_exists=True` when you need a reference to a resource that must already be on the cluster, such as a pre-existing ConfigMap or Secret. - -## Getting the Resource Instance - -The `instance` property fetches the full live resource from the API server: - -```python - pod = Pod(client=client, name="my-pod", namespace="default") - - # Get the full resource instance - inst = pod.instance - print(inst.metadata.name) - print(inst.metadata.uid) - print(inst.metadata.resourceVersion) - - # Convert to a plain dictionary - resource_dict = pod.instance.to_dict() -``` - -> **Note:** Each access to `.instance` makes an API call. Cache the result in a local variable if you need multiple fields from the same snapshot. - -## Retrieving Labels - -The `labels` property returns the resource's labels: - -```python - ns = Namespace(client=client, name="my-namespace") - print(ns.labels) # {'kubernetes.io/metadata.name': 'my-namespace', ...} - - # Check a specific label - if "app" in ns.labels: - print(f"App label: {ns.labels['app']}") -``` - -## Retrieving Resource Status - -### Status phase - -The `status` property returns the current phase (e.g., `Running`, `Pending`, `Active`): - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="my-namespace") - print(ns.status) # "Active" -``` - -Each resource class defines status constants you can compare against: - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-pod", namespace="default") - if pod.status == Pod.Status.RUNNING: - print("Pod is running") -``` - -Common status constants available on all resources: - -| Constant | Value | -|---|---| -| `Status.RUNNING` | `"Running"` | -| `Status.PENDING` | `"Pending"` | -| `Status.SUCCEEDED` | `"Succeeded"` | -| `Status.FAILED` | `"Failed"` | -| `Status.ACTIVE` | `"Active"` | -| `Status.TERMINATING` | `"Terminating"` | -| `Status.COMPLETED` | `"Completed"` | -| `Status.ERROR` | `"Error"` | - -### Condition messages - -Use `get_condition_message()` to retrieve the message for a specific condition type: - -```python - pod = Pod(client=client, name="my-pod", namespace="default") - - # Get message for a condition type - msg = pod.get_condition_message(condition_type="Ready") - print(msg) - - # Optionally filter by condition status - msg = pod.get_condition_message( - condition_type="Ready", - condition_status="False", - ) - print(msg) # Returns empty string if status doesn't match -``` - -**Signature:** -```python - def get_condition_message( - self, - condition_type: str, - condition_status: str = "", - ) -> str -``` - -| Parameter | Type | Description | -|---|---|---| -| `condition_type` | `str` | The condition type to look up (e.g., `"Ready"`, `"Available"`) | -| `condition_status` | `str` | If provided, only return the message when the condition has this status | - -**Returns:** The condition message string, or `""` if not found or status doesn't match. - -### Condition constants - -Resources provide built-in condition constants: - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-pod", namespace="default") - - # Use condition constants - msg = pod.get_condition_message( - condition_type=Pod.Condition.READY, - condition_status=Pod.Condition.Status.TRUE, - ) -``` - -Common condition constants: - -| Constant | Value | -|---|---| -| `Condition.READY` | `"Ready"` | -| `Condition.AVAILABLE` | `"Available"` | -| `Condition.PROGRESSING` | `"Progressing"` | -| `Condition.DEGRADED` | `"Degraded"` | -| `Condition.UPGRADEABLE` | `"Upgradeable"` | -| `Condition.Status.TRUE` | `"True"` | -| `Condition.Status.FALSE` | `"False"` | -| `Condition.Status.UNKNOWN` | `"Unknown"` | - -## Watching for Changes - -### Watch a specific resource - -The `watcher()` method streams events for a specific resource instance: - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-pod", namespace="default") - - for event in pod.watcher(timeout=60): - print(f"Event type: {event['type']}") # ADDED, MODIFIED, DELETED - print(f"Object: {event['object'].metadata.name}") - print(f"Raw: {event['raw_object']}") -``` - -**Signature:** -```python - def watcher( - self, - timeout: int, - resource_version: str = "", - ) -> Generator[dict[str, Any], None, None] -``` - -| Parameter | Type | Description | -|---|---|---| -| `timeout` | `int` | How long to watch (in seconds) | -| `resource_version` | `str` | Only receive events newer than this version. Defaults to the resource version at creation time. | - -Each yielded event is a dict with these keys: - -| Key | Description | -|---|---| -| `type` | Event type: `"ADDED"`, `"MODIFIED"`, or `"DELETED"` | -| `object` | A `ResourceInstance` wrapping the resource | -| `raw_object` | A plain dict representing the resource | - -## Querying Events - -### Stream events for a resource - -The `events()` method watches Kubernetes events associated with a resource: - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-pod", namespace="default") - - for event in pod.events(timeout=30): - print(f"Reason: {event.object.reason}") - print(f"Message: {event.object.message}") -``` - -**Signature:** -```python - def events( - self, - name: str = "", - label_selector: str = "", - field_selector: str = "", - resource_version: str = "", - timeout: int = 240, - ) -> Generator -``` - -| Parameter | Type | Description | -|---|---|---| -| `name` | `str` | Filter by event name | -| `label_selector` | `str` | Filter by labels (comma-separated `key=value`) | -| `field_selector` | `str` | Additional field filters (merged with auto-generated `involvedObject.name` filter) | -| `resource_version` | `str` | Only return events newer than this version | -| `timeout` | `int` | Watch timeout in seconds (default: 240) | - -```python - # Example: filter for Warning events with a specific reason - for event in pod.events( - field_selector="type==Warning,reason==BackOff", - timeout=10, - ): - print(event.object.message) -``` - -### List existing events (non-streaming) - -Use `Event.list()` to get a snapshot of existing events without streaming: - -```python - from ocp_resources.event import Event - - # List Warning events from the last 5 minutes - events = Event.list( - client=client, - namespace="my-namespace", - field_selector="type==Warning", - ) - for event in events: - print(f"{event.reason}: {event.message}") - - # List events from the last 10 minutes - events = Event.list( - client=client, - namespace="my-namespace", - since_seconds=600, - ) -``` - -**Signature:** -```python - @classmethod - def Event.list( - cls, - client: DynamicClient, - namespace: str | None = None, - field_selector: str | None = None, - label_selector: str | None = None, - since_seconds: int = 300, - ) -> list -``` - -Events are returned sorted by timestamp (most recent first). - -### Delete events - -Clean up events before tests to avoid false positives: - -```python - from ocp_resources.event import Event - - Event.delete_events( - client=client, - namespace="my-namespace", - field_selector="reason=AnEventReason", - ) -``` - -## Advanced Usage - -### Handling resources with duplicate API groups - -Some resource kinds exist in multiple API groups. Use `singular_name` to disambiguate: - -The `DNS` kind exists in two API groups, so class-generator writes one module per group: - -- `ocp_resources.dns_config_openshift_io` — `config.openshift.io/v1` -- `ocp_resources.dns_operator_openshift_io` — `operator.openshift.io/v1` - -```python - from ocp_resources.dns_config_openshift_io import DNS - - # When a kind exists in multiple API groups, pass singular_name - for dns in DNS.get(client=client, singular_name="dns"): - print(dns.name) -``` - -### Using `full_api()` for low-level access - -The `full_api()` method returns the raw Kubernetes API resource object, giving you direct access to the API: - -```python - ns = Namespace(client=client, name="my-namespace") - api = ns.full_api() - - # Use the API directly for custom queries - result = api.get( - label_selector="app=nginx", - field_selector="status.phase=Active", - limit=10, - ) -``` - -`full_api()` accepts these keyword arguments: - -| Parameter | Description | -|---|---| -| `pretty` | Pretty-print output | -| `_continue` | Continuation token for paginated results | -| `field_selector` | Filter by fields | -| `label_selector` | Filter by labels | -| `limit` | Maximum number of results | -| `resource_version` | Filter by resource version | -| `timeout_seconds` | Server-side timeout | -| `watch` | Enable watch mode | - -### Converting resources to YAML - -```python - ns = Namespace(client=client, name="my-namespace") - print(ns.to_yaml()) -``` - -### Wait for conditions and status - -For polling-based waiting (e.g., wait until a pod is `Running` or a condition is `True`), see [Waiting for Resource Conditions and Status](waiting-for-conditions.html). - -### Querying during tests with the fake client - -You can use all querying methods with the fake client for unit testing: - -```python - from ocp_resources.resource import get_client - from ocp_resources.namespace import Namespace - - client = get_client(fake=True) - ns = Namespace(client=client, name="test-ns") - ns.deploy() - - # All query methods work the same way - assert ns.exists - assert ns.instance - assert ns.status == Namespace.Status.ACTIVE - - # Listing works too - for ns in Namespace.get(client=client): - print(ns.name) -``` - -See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for details. - -## Troubleshooting - -**`ResourceNotFoundError` when using `ensure_exists=True`** -The resource does not exist on the cluster. Verify the name, namespace, and that the resource has been created before initializing the wrapper. - -**`NotImplementedError: Couldn't find in `** -The resource kind is not available on your cluster. This typically means the CRD has not been installed or the API group is incorrect. - -**`get()` returns no results** -- Verify the `namespace` is correct (omit it for cluster-scoped resources). -- Check your `label_selector` and `field_selector` syntax — use `==` for equality (e.g., `field_selector="status.phase==Running"`). -- Ensure the client has permissions to list the resource type. - -**`status` property raises `AttributeError`** -Not all resources have a `status.phase` field. Access `instance.status` directly to inspect what status fields are available for your resource type. - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Working with Kubernetes Events](working-with-events.html) -- [Common Resource Patterns](common-patterns.html) -- [Resource and NamespacedResource API](resource-api.html) - - ---- - -Source: waiting-for-conditions.md - -# Waiting for Resource Conditions and Status - -Wait for Kubernetes and OpenShift resources to reach a desired status, condition, or lifecycle state using built-in polling methods with configurable timeouts and early exit on failure. - -## Prerequisites - -- `openshift-python-wrapper` installed in your project -- A connected client (see [Connecting to Clusters](connecting-to-clusters.html)) -- A resource instance to wait on (see [Creating and Managing Resources](creating-and-managing-resources.html)) - -## Quick Example - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-pod", namespace="default") - pod.create(wait=True) # Creates the pod and waits for it to exist - - # Wait for the pod to reach "Running" status - pod.wait_for_status(status=Pod.Status.RUNNING) -``` - -## Waiting for a Resource to Exist - -The `wait()` method polls until a resource exists on the cluster. This is useful after creating a resource or when waiting for an external controller to produce one. - -```python - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="my-namespace") - ns.create() - ns.wait(timeout=120, sleep=2) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `timeout` | `int` | `240` (4 minutes) | Maximum seconds to wait | -| `sleep` | `int` | `1` | Seconds between polls | - -**Raises:** `TimeoutExpiredError` if the resource does not appear before the timeout. - -> **Tip:** Pass `wait=True` to `create()` or `deploy()` to combine creation and existence waiting in one call: -> ```python -> pod.create(wait=True) -> # or -> pod.deploy(wait=True) -> ``` - -## Waiting for Deletion - -The `wait_deleted()` method polls until a resource no longer exists: - -```python - pod.delete(wait=True, timeout=120) - - # Or call wait_deleted() separately: - pod.delete() - success = pod.wait_deleted(timeout=120) - if not success: - print("Resource still exists after timeout") -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `timeout` | `int` | `240` (4 minutes) | Maximum seconds to wait | - -**Returns:** `True` if the resource was deleted, `False` if the timeout was reached. - -> **Note:** Unlike most wait methods, `wait_deleted()` returns `False` on timeout rather than raising an exception. - -## Waiting for Status (Phase) - -The `wait_for_status()` method polls `status.phase` until it matches the expected value: - -```python - from ocp_resources.datavolume import DataVolume - - dv = DataVolume(client=client, name="my-dv", namespace="default") - dv.wait_for_status(status=DataVolume.Status.SUCCEEDED, timeout=600) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `status` | `str` | *(required)* | Expected `status.phase` value | -| `timeout` | `int` | `240` (4 minutes) | Maximum seconds to wait | -| `stop_status` | `str \| None` | `Status.FAILED` | Status that aborts the wait immediately | -| `sleep` | `int` | `1` | Seconds between polls | -| `exceptions_dict` | `dict` | Protocol + cluster retry errors | Exceptions to catch and retry | - -**Raises:** `TimeoutExpiredError` if the resource does not reach the desired status, or if `stop_status` is encountered. - -### Using `stop_status` for early failure - -By default, `wait_for_status()` aborts immediately if the resource enters a `"Failed"` phase. Override this to detect a different failure phase: - -```python - from ocp_resources.virtual_machine_instance import VirtualMachineInstance - - vmi = VirtualMachineInstance(client=client, name="my-vmi", namespace="default") - vmi.wait_for_status( - status=VirtualMachineInstance.Status.RUNNING, - stop_status="ErrorUnschedulable", - timeout=300, - ) -``` - -### Built-in status constants - -Every resource inherits status constants from its base class. Resource-specific subclasses extend these with additional values: - -```python - # Base status constants (available on all resources) - from ocp_resources.resource import Resource - - Resource.Status.SUCCEEDED # "Succeeded" - Resource.Status.FAILED # "Failed" - Resource.Status.RUNNING # "Running" - Resource.Status.PENDING # "Pending" - - # Resource-specific statuses - from ocp_resources.virtual_machine import VirtualMachine - - VirtualMachine.Status.MIGRATING # "Migrating" - VirtualMachine.Status.STOPPED # "Stopped" - VirtualMachine.Status.STARTING # "Starting" - VirtualMachine.Status.PAUSED # "Paused" - - from ocp_resources.persistent_volume_claim import PersistentVolumeClaim - - PersistentVolumeClaim.Status.BOUND # "Bound" -``` - -## Waiting for Conditions - -The `wait_for_condition()` method waits for a specific entry in `status.conditions[]` to match a desired type, status, and optionally a reason or message: - -```python - from ocp_resources.user_defined_network import UserDefinedNetwork - - udn = UserDefinedNetwork(client=client, name="my-net", namespace="default") - udn.wait_for_condition( - condition=UserDefinedNetwork.Condition.NETWORK_READY, - status=UserDefinedNetwork.Condition.Status.TRUE, - timeout=30, - ) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `condition` | `str` | *(required)* | Condition type to match (e.g. `"Ready"`, `"Available"`) | -| `status` | `str` | *(required)* | Expected condition status (`"True"`, `"False"`, `"Unknown"`) | -| `timeout` | `int` | `300` (5 minutes) | Maximum seconds to wait | -| `sleep_time` | `int` | `1` | Seconds between polls | -| `reason` | `str \| None` | `None` | If set, the condition's `reason` field must also match | -| `message` | `str` | `""` | Text that must appear in the condition's `message` field | -| `stop_condition` | `str \| None` | `None` | A condition type that aborts the wait immediately | -| `stop_status` | `str` | `"True"` | The status of the stop condition that triggers early abort | - -**Raises:** -- `TimeoutExpiredError` — if the condition is not met before the timeout -- `ConditionError` — if the `stop_condition` is detected before the desired condition - -### Matching by reason and message - -```python - pod.wait_for_condition( - condition="Ready", - status="True", - reason="PodCompleted", - message="containers with", - timeout=120, - ) -``` - -The `reason` must be an exact match. The `message` is checked using substring inclusion — the condition's message field must *contain* the provided string. - -### Using `stop_condition` for early abort - -Stop conditions let you fail fast when an unrecoverable condition appears: - -```python - from ocp_resources.exceptions import ConditionError - - try: - resource.wait_for_condition( - condition="Ready", - status="True", - stop_condition="Failed", - stop_status="True", - timeout=300, - ) - except ConditionError as e: - print(f"Resource hit a failure condition: {e}") -``` - -> **Note:** The `stop_condition` matching only checks the condition `type` and `status`. The `reason` and `message` parameters are ignored when evaluating stop conditions. - -### Built-in condition constants - -```python - from ocp_resources.resource import Resource - - # Condition types - Resource.Condition.READY # "Ready" - Resource.Condition.AVAILABLE # "Available" - Resource.Condition.DEGRADED # "Degraded" - Resource.Condition.PROGRESSING # "Progressing" - Resource.Condition.UPGRADEABLE # "Upgradeable" - Resource.Condition.NETWORK_READY # "NetworkReady" - - # Condition statuses - Resource.Condition.Status.TRUE # "True" - Resource.Condition.Status.FALSE # "False" - Resource.Condition.Status.UNKNOWN # "Unknown" - - # Condition reasons - Resource.Condition.Reason.ALL_REQUIREMENTS_MET # "AllRequirementsMet" - Resource.Condition.Reason.INSTALL_SUCCEEDED # "InstallSucceeded" -``` - -## Waiting with Context Managers - -When using a resource as a context manager, set `wait_for_resource=True` to automatically wait for the resource to exist after creation: - -```python - from ocp_resources.pod import Pod - - with Pod( - client=client, - name="my-pod", - namespace="default", - wait_for_resource=True, - ) as pod: - # Pod exists when you reach this line - pod.wait_for_status(status=Pod.Status.RUNNING) -``` - -See [Creating and Managing Resources](creating-and-managing-resources.html) for more on context manager usage. - -## Advanced Usage - -### Waiting for any conditions to appear - -The `wait_for_conditions()` method waits up to 30 seconds for the resource's `status.conditions` list to be populated (non-empty). This is useful when you need to inspect conditions but aren't sure they've been set yet: - -```python - resource.wait_for_conditions() - # Now resource.instance.status.conditions is available -``` - -### Resource-specific wait methods - -Many resource types provide specialized wait methods beyond the base `wait_for_status()` and `wait_for_condition()`. These methods encapsulate domain-specific logic. - -#### Deployments - -```python - from ocp_resources.deployment import Deployment - - deploy = Deployment(client=client, name="my-app", namespace="default") - deploy.wait_for_replicas(deployed=True, timeout=240) # All replicas ready - deploy.wait_for_replicas(deployed=False, timeout=120) # Scale to zero complete -``` - -#### DaemonSets - -```python - from ocp_resources.daemonset import DaemonSet - - ds = DaemonSet(client=client, name="my-ds", namespace="default") - ds.wait_until_deployed(timeout=240) # All desired pods are ready -``` - -#### VirtualMachines (KubeVirt) - -```python - from ocp_resources.virtual_machine import VirtualMachine - from ocp_resources.virtual_machine_instance import VirtualMachineInstance - - vm = VirtualMachine(client=client, name="my-vm", namespace="default") - - # Wait for VM ready status (True = running, None = stopped) - vm.wait_for_ready_status(status=True, timeout=240) - - # Wait for a specific status field to become None - vm.wait_for_status_none(status="snapshotInProgress", timeout=240) - - # Wait for VMI to be running - vmi = VirtualMachineInstance(client=client, name="my-vm", namespace="default") - vmi.wait_until_running(timeout=240, logs=True) - - # Wait for VMI pause state - vmi.wait_for_pause_status(pause=True, timeout=240) -``` - -See [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) for the full VM lifecycle. - -#### DataVolumes - -```python - from ocp_resources.datavolume import DataVolume - - dv = DataVolume(client=client, name="my-dv", namespace="default") - dv.wait_for_dv_success( - timeout=600, - failure_timeout=120, - pvc_wait_for_bound_timeout=60, - ) -``` - -The `wait_for_dv_success()` method handles the full DataVolume lifecycle: it waits for the status to leave `Pending`, then waits for `Succeeded`, and finally confirms the PVC is `Bound`. - -You can pass a `stop_status_func` callback to abort early on custom conditions: - -```python - def dv_is_stuck(dv): - return dv.instance.status.conditions.restartCount > 3 - - - dv.wait_for_dv_success( - stop_status_func=dv_is_stuck, - dv=dv, # passed as keyword argument to the function - ) -``` - -#### VirtualMachineSnapshot / VirtualMachineRestore - -```python - from ocp_resources.virtual_machine_snapshot import VirtualMachineSnapshot - from ocp_resources.virtual_machine_restore import VirtualMachineRestore - - snapshot = VirtualMachineSnapshot(client=client, name="snap-1", namespace="default") - snapshot.wait_snapshot_done(timeout=240) # readyToUse + VM snapshotInProgress is None - - restore = VirtualMachineRestore(client=client, name="restore-1", namespace="default") - restore.wait_restore_done(timeout=240) # complete + VM restoreInProgress is None -``` - -#### MachineSet - -```python - from ocp_resources.machine_set import MachineSet - - ms = MachineSet(client=client, name="worker-set", namespace="openshift-machine-api") - ms.wait_for_replicas(timeout=300, sleep=1) -``` - -Returns `True` when ready replicas match desired replicas, `False` on timeout. - -#### NodeNetworkConfigurationPolicy - -```python - from ocp_resources.node_network_configuration_policy import NodeNetworkConfigurationPolicy - - nncp = NodeNetworkConfigurationPolicy(client=client, name="br1-policy") - nncp.wait_for_status_success() # Uses the resource's own success_timeout -``` - -Raises `NNCPConfigurationFailed` if the policy fails or finds no matching nodes. - -### Timeout constants - -The library provides predefined timeout constants you can use with any wait method: - -```python - from ocp_resources.utils.constants import ( - TIMEOUT_1SEC, # 1 - TIMEOUT_5SEC, # 5 - TIMEOUT_10SEC, # 10 - TIMEOUT_30SEC, # 30 - TIMEOUT_1MINUTE, # 60 - TIMEOUT_2MINUTES, # 120 - TIMEOUT_4MINUTES, # 240 - TIMEOUT_10MINUTES, # 600 - ) - - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=TIMEOUT_10MINUTES) -``` - -### Custom exception retries - -Wait methods handle transient cluster errors automatically (connection resets, etcd leader changes, protocol errors). You can customize which exceptions are retried: - -```python - from ocp_resources.utils.constants import ( - PROTOCOL_ERROR_EXCEPTION_DICT, - DEFAULT_CLUSTER_RETRY_EXCEPTIONS, - ) - - # Combine default retry exceptions - pod.wait_for_status( - status=Pod.Status.RUNNING, - exceptions_dict={ - **PROTOCOL_ERROR_EXCEPTION_DICT, - **DEFAULT_CLUSTER_RETRY_EXCEPTIONS, - }, - ) -``` - -The default retry exceptions cover: -- `ProtocolError` — network-level failures -- `MaxRetryError`, `ConnectionAbortedError`, `ConnectionResetError` — connection failures -- `InternalServerError` — etcd leader changes, webhook failures, RPC errors -- `ServerTimeoutError` — API server timeouts -- `ForbiddenError` — context deadline exceeded errors - -## Troubleshooting - -### `TimeoutExpiredError` is raised unexpectedly - -- **Increase the timeout.** The default is 240 seconds (4 minutes), which may not be enough for slow operations like DataVolume imports or VM migrations. -- **Check `stop_status`.** `wait_for_status()` defaults `stop_status` to `Status.FAILED`. If your resource legitimately passes through a `"Failed"` phase, set `stop_status` to a value that won't match, or set it to `None`. -- **Inspect the current status.** The error log includes the last observed status. Read it to understand where the resource got stuck. - -### `ConditionError` on `wait_for_condition()` - -This means the `stop_condition` was matched before the desired condition. The error message includes the full condition details. Review your `stop_condition` and `stop_status` settings. - -### Resource exists but `wait_for_status()` times out - -The `wait_for_status()` method reads from `status.phase`. Some resources use different status fields (e.g., `status.ready`, `status.readyToUse`, `status.syncStatus`). For those resources, use the resource-specific wait methods documented above, or use `wait_for_condition()` to match on `status.conditions[]` entries instead. - -## Related Pages - -- [Querying and Watching Resources](querying-resources.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) -- [Error Handling and Exception Reference](error-handling.html) -- [Resource and NamespacedResource API](resource-api.html) - - ---- - -Source: validating-resources.md - -# Validating Resources Against OpenAPI Schemas - -Catch configuration mistakes before they reach the Kubernetes API by validating resource definitions against OpenAPI schemas. This saves debugging time and prevents failed deployments caused by typos, missing fields, or incorrect types. - -## Prerequisites - -- openshift-python-wrapper installed (see [Installing and Creating Your First Resource](quickstart.html)) -- A connection to a cluster, or use the fake client for offline validation (see [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html)) - -## Quick Example - -```python - from ocp_resources.pod import Pod - - pod = Pod( - name="nginx-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - - # Validate against the OpenAPI schema — raises ValidationError if invalid - pod.validate() -``` - -If the resource is valid, `validate()` returns silently. If something is wrong, it raises a `ValidationError` with a clear message pointing to the problematic field. - -## Manual Validation - -Call `.validate()` on any resource instance to check it against the OpenAPI schema for its kind: - -```python - from ocp_resources.exceptions import ValidationError - from ocp_resources.service import Service - - service = Service( - name="nginx-service", - namespace="default", - selector={"app": "nginx"}, - ports=[{"port": 80, "targetPort": 80, "protocol": "TCP"}], - ) - - try: - service.validate() - print("Service configuration is valid") - except ValidationError as e: - print(f"Validation failed: {e}") -``` - -> **Tip:** Use manual validation in CI pipelines or pre-commit hooks to catch errors before applying resources to a cluster. - -## Validating Dictionaries Without Creating Objects - -Use the `validate_dict()` class method to validate a raw resource dictionary without instantiating a resource object: - -```python - from ocp_resources.deployment import Deployment - from ocp_resources.exceptions import ValidationError - - deployment_dict = { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": {"name": "nginx-deployment", "namespace": "default"}, - "spec": { - "replicas": 3, - "selector": {"matchLabels": {"app": "nginx"}}, - "template": { - "metadata": {"labels": {"app": "nginx"}}, - "spec": { - "containers": [{"name": "nginx", "image": "nginx:1.21", "ports": [{"containerPort": 80}]}], - }, - }, - }, - } - - try: - Deployment.validate_dict(resource_dict=deployment_dict) - print("Deployment configuration is valid") - except ValidationError as e: - print(f"Invalid: {e}") -``` - -This is useful when you have a dictionary from a YAML file or external source and want to validate it before creating the resource. - -## Enabling Auto-Validation on Create - -Set `schema_validation_enabled=True` when creating a resource instance to automatically validate before every `create()`, `deploy()`, or `update_replace()` call: - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ValidationError - - pod = Pod( - client=client, - name="auto-validated-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - schema_validation_enabled=True, - ) - - # Validation runs automatically before the resource is sent to the API - pod.deploy() -``` - -If validation fails, the resource is **not** created and a `ValidationError` is raised: - -```python - invalid_pod = Pod( - client=client, - name="bad-pod", - namespace="default", - containers=[{"name": "nginx"}], # Missing required 'image' field - schema_validation_enabled=True, - ) - - try: - invalid_pod.deploy() - except ValidationError as e: - print(f"Blocked: {e}") -``` - -> **Note:** Auto-validation is **disabled by default** (`schema_validation_enabled=False`). You must opt in per instance. - -### When Auto-Validation Runs - -| Operation | Auto-validates? | Notes | -|--------------------|-----------------|---------------------------------------------| -| `create()` / `deploy()` | ✅ Yes | Validates the full resource before sending | -| `update_replace()` | ✅ Yes | Validates the replacement dictionary | -| `update()` | ❌ No | Sends a partial patch, not a full resource | - -Auto-validation also applies when using context managers, since they call `deploy()` internally. See [Creating and Managing Resources](creating-and-managing-resources.html) for details on resource lifecycle methods. - -### Toggling Validation at Runtime - -You can enable or disable validation on an existing instance: - -```python - pod = Pod( - client=client, - name="my-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - - # Enable after creation - pod.schema_validation_enabled = True - pod.deploy() # Will validate first - - # Disable for a subsequent operation - pod.schema_validation_enabled = False -``` - -## Advanced Usage - -### Validating Resources with Duplicate API Groups - -Some resource kinds (like `DNS`) exist in multiple API groups. The validator uses the resource's `api_group` to select the correct schema automatically: - -```python - # These two resources share the kind "DNS" but use different API groups. - # Each is validated against its own schema. - dns_config.validate() # Uses config.openshift.io schema - dns_operator.validate() # Uses operator.openshift.io schema -``` - -See [Common Resource Patterns](common-patterns.html) for more on working with resources that share a kind name. - -### Pre-Warming the Schema Cache - -The first validation for a resource kind loads and resolves the schema (~25ms). Subsequent validations for the same kind use a cache and are much faster (~2ms). Pre-warm the cache at application startup for critical resource types: - -```python - from ocp_resources.pod import Pod - from ocp_resources.deployment import Deployment - from ocp_resources.service import Service - from ocp_resources.exceptions import ValidationError - - for resource_cls in [Pod, Deployment, Service]: - try: - resource_cls.validate_dict({"apiVersion": "v1", "kind": resource_cls.kind, "metadata": {"name": "warmup"}}) - except ValidationError: - pass # Expected — we just want to load the schemas -``` - -### Disabling Validation for Bulk Operations - -When creating many resources in a loop, disable auto-validation to avoid repeated checks if you've already validated your templates: - -```python - # Validate the template once - Pod.validate_dict(resource_dict=pod_template) - - # Then create many instances without per-instance validation - for i in range(100): - pod = Pod( - client=client, - name=f"worker-{i}", - namespace="default", - containers=[{"name": "app", "image": "myapp:latest"}], - schema_validation_enabled=False, # Skip — already validated - ) - pod.deploy() -``` - -## Troubleshooting - -### Reading Validation Error Messages - -Validation errors include three key pieces of information: - -``` - Resource validation failed for Pod/my-pod - Field: spec.containers[0].image - Error: 123 is not of type 'string' - Expected type: string -``` - -- **Resource identifier** — which resource failed (kind and name) -- **Field path** — the JSON path to the problematic field -- **Error details** — what went wrong and what was expected - -### Common Validation Error Patterns - -| Error message pattern | Cause | Fix | -|----------------------------------------------|-------------------------------------|--------------------------------------------------| -| `'X' is a required property` | Missing a required field | Add the missing field to your resource definition | -| `'X' is not of type 'Y'` | Wrong data type | Use the correct type (e.g., `3` not `"3"`) | -| `'X' does not match pattern` | Invalid format | Follow Kubernetes naming rules (lowercase, alphanumeric, hyphens) | -| `'X' is not one of [...]` | Invalid enum value | Use one of the allowed values listed in the error | -| `Additional properties are not allowed` | Unknown field name | Check for typos in field names | - -### Validation Passes But API Rejects the Resource - -OpenAPI schema validation catches structural issues (wrong types, missing fields, invalid formats) but cannot check everything the API server validates. For example: - -- Unique name constraints within a namespace -- Resource quota limits -- Admission webhook rules -- Cross-resource references (e.g., a ConfigMap that doesn't exist) - -If validation passes but the API still rejects your resource, the issue is likely a server-side constraint. - -### No Schema Available for a Resource - -If no schema is bundled for a resource kind, `validate()` silently succeeds instead of raising an error. A debug-level log message is emitted: - -``` - No schema found for MyCustomResource, skipping validation -``` - -To update bundled schemas, see [class-generator CLI Reference](class-generator-cli.html). - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Schema Validation and Code Generation Architecture](schema-validation-internals.html) -- [Error Handling and Exception Reference](error-handling.html) -- [class-generator CLI Reference](class-generator-cli.html) -- [Resource and NamespacedResource API](resource-api.html) - - ---- - -Source: managing-resource-lists.md - -# Managing Bulk Resources with ResourceList - -When you need to create several copies of the same resource or deploy a resource across multiple namespaces, doing it one at a time is tedious and error-prone. `ResourceList` and `NamespacedResourceList` let you create, deploy, and clean up groups of resources in a single operation. - -## Prerequisites - -- A working cluster connection (see [Connecting to Clusters](connecting-to-clusters.html)) -- Familiarity with creating individual resources (see [Creating and Managing Resources](creating-and-managing-resources.html)) - -## Quick Example - -Create three namespaces and deploy them in two lines: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import ResourceList, get_client - - client = get_client() - - namespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name="my-ns") - namespaces.deploy() - # Creates: my-ns-1, my-ns-2, my-ns-3 -``` - -Or use a context manager for automatic cleanup: - -```python - with ResourceList(client=client, resource_class=Namespace, name="my-ns", num_resources=3) as namespaces: - print(namespaces[0].name) # "my-ns-1" - # All three namespaces are deleted when the block exits -``` - -## Creating Multiple Copies of a Resource - -`ResourceList` creates N copies of a resource type, each with an auto-generated name based on a base name you provide. - -**Step 1:** Choose a resource class and specify how many copies you want: - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import ResourceList, get_client - - client = get_client() - namespaces = ResourceList( - resource_class=Namespace, - num_resources=3, - client=client, - name="test-ns", - ) -``` - -This creates three `Namespace` objects named `test-ns-1`, `test-ns-2`, and `test-ns-3`. They are not yet deployed to the cluster. - -**Step 2:** Deploy all resources: - -```python - namespaces.deploy() -``` - -**Step 3:** Work with individual resources by index or iteration: - -```python - # Access by index - first_ns = namespaces[0] - print(first_ns.name) # "test-ns-1" - - # Iterate over all resources - for ns in namespaces: - print(ns.name) - - # Check how many resources are in the list - print(len(namespaces)) # 3 -``` - -**Step 4:** Clean up all resources when done: - -```python - namespaces.clean_up() -``` - -> **Note:** `clean_up()` deletes resources in reverse order. This ensures dependent resources are removed before the resources they depend on. - -## Deploying One Resource Per Namespace - -`NamespacedResourceList` creates one instance of a namespaced resource in each namespace from a `ResourceList`. - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.pod import Pod - from ocp_resources.resource import NamespacedResourceList, ResourceList, get_client - - client = get_client() - - # First, create the namespaces - namespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name="app-ns") - namespaces.deploy() - - # Then create one pod in each namespace - pods = NamespacedResourceList( - client=client, - resource_class=Pod, - namespaces=namespaces, - name="web-server", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - pods.deploy() -``` - -This creates three pods, all named `web-server`, one in each namespace (`app-ns-1`, `app-ns-2`, `app-ns-3`). - -```python - for pod in pods: - print(f"{pod.name} in {pod.namespace}") - # web-server in app-ns-1 - # web-server in app-ns-2 - # web-server in app-ns-3 -``` - -> **Warning:** The `namespaces` parameter must be a `ResourceList` containing only Namespace resources. Passing any other resource type raises a `TypeError`. - -## Passing Extra Resource Parameters - -Any additional keyword arguments you pass are forwarded to each resource's constructor. This lets you configure resources beyond just the name: - -```python - from ocp_resources.role import Role - from ocp_resources.resource import NamespacedResourceList - - roles = NamespacedResourceList( - client=client, - resource_class=Role, - namespaces=namespaces, - name="viewer-role", - rules=[ - { - "apiGroups": [""], - "resources": ["pods"], - "verbs": ["get", "list", "watch"], - } - ], - ) - roles.deploy() -``` - -## Advanced Usage - -### Context Managers for Automatic Lifecycle - -Both `ResourceList` and `NamespacedResourceList` support context managers. Resources are deployed on entry and cleaned up on exit — ideal for tests: - -```python - with ResourceList(client=client, resource_class=Namespace, name="test-ns", num_resources=3) as namespaces: - with NamespacedResourceList( - client=client, - resource_class=Pod, - namespaces=namespaces, - name="test-pod", - containers=[{"name": "app", "image": "busybox"}], - ) as pods: - # All namespaces and pods are deployed - assert len(pods) == 3 - # Pods are cleaned up here - # Namespaces are cleaned up here -``` - -> **Tip:** Nest the context managers so that namespaced resources are cleaned up before their namespaces are deleted. - -### Waiting During Deploy and Cleanup - -Pass `wait=True` to `deploy()` to wait for each resource to reach a ready state. By default, `clean_up()` already waits for deletion to complete. - -```python - namespaces = ResourceList(resource_class=Namespace, num_resources=2, client=client, name="ns") - namespaces.deploy(wait=True) - - # Later... - namespaces.clean_up(wait=True) # wait=True is the default - namespaces.clean_up(wait=False) # Skip waiting for deletion -``` - -For more on waiting for specific conditions, see [Waiting for Resource Conditions and Status](waiting-for-conditions.html). - -### Comparison: ResourceList vs NamespacedResourceList - -| Feature | `ResourceList` | `NamespacedResourceList` | -|---|---|---| -| **Purpose** | Create N copies of any resource | Create one resource per namespace | -| **Naming** | Auto-generates `{name}-1`, `{name}-2`, … | Same name in each namespace | -| **Required params** | `resource_class`, `num_resources`, `name` | `resource_class`, `namespaces`, `name` | -| **Use case** | Multiple namespaces, nodes, cluster-scoped resources | Pods, services, roles — anything namespace-scoped | -| **Context manager** | ✅ | ✅ | - -## Troubleshooting - -**TypeError: "All the resources in namespaces should be namespaces"** - -You passed a `ResourceList` that contains non-Namespace resources to `NamespacedResourceList`. Make sure the `namespaces` parameter is a `ResourceList` created with `resource_class=Namespace`. - -**Resources not appearing on the cluster** - -Creating a `ResourceList` or `NamespacedResourceList` only instantiates the Python objects — it does not deploy them. Call `.deploy()` or use a context manager (`with ... as`) to actually create the resources on the cluster. - -**Cleanup order issues** - -Always clean up namespaced resources before their namespaces. When using context managers, nest them so the inner block (namespaced resources) exits first. When calling `clean_up()` manually, clean up dependent resources before their parents. - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) -- [Common Resource Patterns](common-patterns.html) -- [Resource and NamespacedResource API](resource-api.html) - - ---- - -Source: editing-resources-temporarily.md - -# Editing Resources Temporarily with ResourceEditor - -Temporarily modify Kubernetes and OpenShift resources during tests and have their original values automatically restored when you're done. This is essential for test isolation—ensuring that changes made during a test don't leak into other tests or persist in the cluster. - -## Prerequisites - -- `openshift-python-wrapper` installed in your project (see [Installing and Creating Your First Resource](quickstart.html)) -- A cluster connection established with a privileged client (see [Connecting to Clusters](connecting-to-clusters.html)) -- One or more existing resources on the cluster that you want to temporarily modify - -> **Warning:** The client used to retrieve resources must have sufficient privileges. Do not use an unprivileged user client with `ResourceEditor`. - -## Quick Example - -```python - from ocp_resources.resource import ResourceEditor - from ocp_resources.node import Node - - node = Node(name="worker-0") - - with ResourceEditor(patches={node: {"metadata": {"labels": {"test-label": "true"}}}}): - # The node now has the label "test-label": "true" - # Run your test logic here... - pass - - # The label is automatically removed when the block exits -``` - -That's it—when the `with` block ends (even if an exception occurs), the original resource state is restored. - -## Step-by-Step: Using ResourceEditor as a Context Manager - -### 1. Import and identify your target resource - -```python - from ocp_resources.resource import ResourceEditor - from ocp_resources.config_map import ConfigMap - - cm = ConfigMap(name="my-config", namespace="my-namespace") -``` - -### 2. Define your patch - -Patches are dictionaries that mirror the resource's YAML structure. Only include the fields you want to change: - -```python - patch = {"data": {"feature_flag": "enabled", "log_level": "debug"}} -``` - -### 3. Wrap your test logic in a `with` block - -```python - with ResourceEditor(patches={cm: patch}): - # ConfigMap now has updated data fields - assert cm.instance.data.feature_flag == "enabled" - # Run tests that depend on the modified config... -``` - -### 4. Original values are restored automatically - -When the `with` block exits, the original `data` values are written back to the resource. Fields that didn't exist before the patch are removed (set to `None` in the restore payload, which tells the API to delete them). - -## Patching Multiple Resources at Once - -Pass multiple resource-patch pairs in a single `ResourceEditor`: - -```python - from ocp_resources.resource import ResourceEditor - from ocp_resources.namespace import Namespace - from ocp_resources.config_map import ConfigMap - - ns = Namespace(name="test-ns") - cm = ConfigMap(name="app-config", namespace="test-ns") - - patches = { - ns: {"metadata": {"labels": {"env": "test"}}}, - cm: {"data": {"database_url": "postgres://test-db:5432"}}, - } - - with ResourceEditor(patches=patches): - # Both resources are patched; run tests here - pass - # Both resources are restored to their original state -``` - -## Using update() and restore() Without a Context Manager - -If you need finer control over when patches are applied and reverted—for example, in `setup`/`teardown` fixtures—use the `update()` and `restore()` methods directly: - -```python - from ocp_resources.resource import ResourceEditor - from ocp_resources.config_map import ConfigMap - - cm = ConfigMap(name="app-config", namespace="default") - - editor = ResourceEditor(patches={cm: {"data": {"mode": "maintenance"}}}) - - # Apply the patch and create backups - editor.update(backup_resources=True) - - # ... run tests ... - - # Restore original values - editor.restore() -``` - -> **Note:** When calling `update()` manually, pass `backup_resources=True` to ensure original values are captured. Without this flag, no backup is created and `restore()` will have nothing to revert. - -## Advanced Usage - -### Replace Instead of Patch - -By default, `ResourceEditor` uses the `"update"` action, which sends a merge-patch (`application/merge-patch+json`). This merges your changes into the existing resource. If you need to *replace* the entire resource (for example, to remove fields that merge-patch can't delete), use `action="replace"`: - -```python - from ocp_resources.resource import ResourceEditor - - with ResourceEditor( - patches={my_resource: {"spec": {"replicas": 3}}}, - action="replace", - ): - # The resource is fully replaced, not merged - pass -``` - -| Action | Method Used | Behavior | -|---|---|---| -| `"update"` (default) | Merge patch | Merges patch into existing resource; cannot remove fields by omission | -| `"replace"` | Full replacement | Replaces the entire resource; fields not in the patch are removed | - -> **Warning:** With `action="replace"`, the patch must include all required fields for the resource, not just the fields you want to change. The replacement payload automatically includes `metadata.name`, `metadata.namespace`, `metadata.resourceVersion`, `kind`, and `apiVersion`. - -### Providing Your Own Backups - -If you already know what the restore state should be (or need custom restore logic), pass `user_backups` to skip the automatic backup calculation: - -```python - from ocp_resources.resource import ResourceEditor - - custom_backup = {my_resource: {"spec": {"replicas": 1}}} - - with ResourceEditor( - patches={my_resource: {"spec": {"replicas": 5}}}, - user_backups=custom_backup, - ): - # Resource scaled to 5 replicas - pass - # Restored to 1 replica (your custom backup), regardless of what the original value was -``` - -### Inspecting Backups - -Access the automatically generated backup data through the `backups` property: - -```python - editor = ResourceEditor(patches={my_resource: {"metadata": {"labels": {"env": "staging"}}}}) - editor.update(backup_resources=True) - - # See what was backed up - print(editor.backups) - # {: {'metadata': {'labels': {'env': 'production'}}}} - - editor.restore() -``` - -### No-Op Detection - -`ResourceEditor` automatically detects when a patch produces no actual changes. If the patch values already match the resource's current state, the resource is skipped entirely and a warning is logged: - -``` - ResourceEdit: no diff found in patch for my-resource -- skipping -``` - -This prevents unnecessary API calls and avoids triggering watch events for no-op changes. - -### Automatic Retry on Conflicts - -All patch and restore operations are wrapped with automatic retry logic. If a `ConflictError` occurs (for example, due to a concurrent update changing the `resourceVersion`), the operation is retried automatically with a 5-second sleep interval and a 30-second timeout. - -## API Reference - -### Constructor - -```python - ResourceEditor( - patches: dict, - action: str = "update", - user_backups: dict | None = None, - ) -``` - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `patches` | `dict` | *(required)* | Mapping of resource objects to patch dicts: `{resource: {yaml_patch}}` | -| `action` | `str` | `"update"` | Either `"update"` (merge patch) or `"replace"` (full replacement) | -| `user_backups` | `dict \| None` | `None` | Custom backup dicts to use for restore instead of auto-generated ones | - -### Properties - -| Property | Return Type | Description | -|---|---|---| -| `backups` | `dict` | The backup data captured for each patched resource: `{resource: backup_dict}` | -| `patches` | `dict` | The patches dict provided in the constructor | - -### Methods - -| Method | Parameters | Description | -|---|---|---| -| `update(backup_resources=False)` | `backup_resources: bool` | Apply patches. Set `backup_resources=True` to capture original values for later restore. | -| `restore()` | — | Re-apply the stored backup dicts to revert all patched resources. | - -### Context Manager Protocol - -| Method | Behavior | -|---|---| -| `__enter__()` | Calls `update(backup_resources=True)` and returns the `ResourceEditor` instance | -| `__exit__()` | Calls `restore()` to revert all changes | - -## Troubleshooting - -| Problem | Cause | Solution | -|---|---|---| -| Restore doesn't revert changes | Called `update()` without `backup_resources=True` | Always pass `backup_resources=True` when calling `update()` manually, or use the context manager (`with` block) which does this automatically | -| `NotFoundError` during backup | Resource cannot be found by name (known with some CRDs like `ServiceMonitor`) | `ResourceEditor` handles this automatically by falling back to a field-selector query | -| `ConflictError` during patch | Another process modified the resource concurrently | Automatic retries handle this; if it persists beyond 30 seconds, check for controllers continuously modifying the resource | -| Patch has no effect | The resource already has the values in your patch | This is expected—`ResourceEditor` logs a warning and skips the resource. Check that you're patching the correct fields. | -| `replace` action fails | Patch is missing required fields | When using `action="replace"`, provide all required spec fields since the entire resource is replaced | - -> **Tip:** For creating and cleaning up resources (rather than editing existing ones), see [Creating and Managing Resources](creating-and-managing-resources.html). For managing groups of similar resources, see [Managing Bulk Resources with ResourceList](managing-resource-lists.html). - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Resource and NamespacedResource API](resource-api.html) -- [Common Resource Patterns](common-patterns.html) -- [Managing Bulk Resources with ResourceList](managing-resource-lists.html) -- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) - - ---- - -Source: working-with-virtual-machines.md - -# Working with Virtual Machines (KubeVirt) - -Manage the full lifecycle of KubeVirt virtual machines on OpenShift — create VMs, start and stop them, live-migrate between nodes, take snapshots, and interact with their running instances — all from Python. - -## Prerequisites - -- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html)) -- A cluster connection configured (see [Connecting to Clusters](connecting-to-clusters.html)) -- KubeVirt / OpenShift Virtualization installed on your cluster - -## Quick Example - -```python - from ocp_resources.virtual_machine import VirtualMachine - - vm = VirtualMachine( - name="my-vm", - namespace="my-namespace", - body={ - "spec": { - "domain": { - "devices": {"disks": [{"disk": {"bus": "virtio"}, "name": "rootdisk"}]}, - "resources": {"requests": {"memory": "1Gi"}}, - }, - "volumes": [{"name": "rootdisk", "containerDisk": {"image": "quay.io/containerdisks/fedora"}}], - } - }, - ) - - with vm: - vm.start(wait=True) - print(f"VM ready: {vm.ready}") # True - print(f"Status: {vm.printable_status}") # "Running" - vm.stop(wait=True) - # VM is automatically cleaned up when the context manager exits -``` - -## Creating a VirtualMachine - -```python - from ocp_resources.virtual_machine import VirtualMachine - - vm = VirtualMachine( - name="my-vm", - namespace="my-namespace", - body={ - "spec": { - "domain": { - "devices": {"disks": [{"disk": {"bus": "virtio"}, "name": "rootdisk"}]}, - "resources": {"requests": {"memory": "2Gi"}}, - }, - "volumes": [ - { - "name": "rootdisk", - "containerDisk": {"image": "quay.io/containerdisks/fedora"}, - } - ], - } - }, - ) - vm.deploy() -``` - -The `body` parameter accepts a dictionary that maps to the KubeVirt VM `spec.template` structure. If you omit `body`, a minimal skeleton (`{"template": {"spec": {}}}`) is created automatically. - -You can also create a VM from a YAML file: - -```python - vm = VirtualMachine( - name="my-vm", - namespace="my-namespace", - yaml_file="vm-definition.yaml", - ) - vm.deploy() -``` - -### Constructor Parameters - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `name` | `str` | `None` | Name of the VirtualMachine resource | -| `namespace` | `str` | `None` | Target namespace | -| `client` | `DynamicClient` | `None` | Kubernetes client; auto-resolved if omitted | -| `body` | `dict` | `None` | VM spec body dictionary | -| `teardown` | `bool` | `True` | Whether to delete the resource on cleanup | -| `yaml_file` | `str` | `None` | Path to a YAML file defining the VM | -| `delete_timeout` | `int` | `240` (seconds) | Timeout for delete operations | - -> **Tip:** Use the context manager (`with vm:`) to ensure automatic cleanup. See [Creating and Managing Resources](creating-and-managing-resources.html) for details on `deploy()`, `clean_up()`, and context managers. - -## Starting, Stopping, and Restarting - -### Start a VM - -```python - # Fire-and-forget - vm.start() - - # Wait until the VM is ready - vm.start(wait=True) - - # Custom timeout (seconds) - vm.start(timeout=600, wait=True) -``` - -### Stop a VM - -```python - # Fire-and-forget - vm.stop() - - # Wait for the VM to stop and the VMI to be deleted - vm.stop(wait=True) - - # Custom timeouts - vm.stop(timeout=300, vmi_delete_timeout=300, wait=True) -``` - -### Restart a VM - -```python - vm.restart(wait=True) -``` - -When `wait=True`, `restart()` waits for the old virt-launcher pod to be deleted and the new VMI to reach the `Running` state. - -## Checking VM Status - -```python - # Boolean ready status: True if running, None if stopped - vm.ready # True - - # Human-readable status string - vm.printable_status # "Running", "Stopped", "Starting", "Migrating", etc. - - # Wait for ready status - vm.wait_for_ready_status(status=True, timeout=300) # Wait for running - vm.wait_for_ready_status(status=None, timeout=300) # Wait for stopped - - # Wait for a specific status field to clear - vm.wait_for_status_none(status="snapshotInProgress") -``` - -### Run Strategy Constants - -Use built-in constants for the `runStrategy` field: - -```python - VirtualMachine.RunStrategy.ALWAYS # "Always" - VirtualMachine.RunStrategy.HALTED # "Halted" - VirtualMachine.RunStrategy.MANUAL # "Manual" - VirtualMachine.RunStrategy.RERUNONFAILURE # "RerunOnFailure" -``` - -### Status Constants - -```python - VirtualMachine.Status.STARTING # "Starting" - VirtualMachine.Status.RUNNING # "Running" (inherited) - VirtualMachine.Status.STOPPED # "Stopped" - VirtualMachine.Status.STOPPING # "Stopping" - VirtualMachine.Status.MIGRATING # "Migrating" - VirtualMachine.Status.PAUSED # "Paused" - VirtualMachine.Status.PROVISIONING # "Provisioning" - VirtualMachine.Status.ERROR_UNSCHEDULABLE # "ErrorUnschedulable" - VirtualMachine.Status.DATAVOLUME_ERROR # "DataVolumeError" -``` - -## Working with VirtualMachineInstances - -Every running VM has an associated `VirtualMachineInstance` (VMI). Access it through the VM or create one directly. - -### Accessing the VMI from a VM - -```python - vmi = vm.vmi # Returns a VirtualMachineInstance object -``` - -### Creating a Standalone VMI - -```python - from ocp_resources.virtual_machine_instance import VirtualMachineInstance - - vmi = VirtualMachineInstance( - name="my-vmi", - namespace="my-namespace", - domain={ - "devices": {"disks": [{"disk": {"bus": "virtio"}, "name": "rootdisk"}]}, - "resources": {"requests": {"memory": "1Gi"}}, - }, - volumes=[{"name": "rootdisk", "containerDisk": {"image": "quay.io/containerdisks/fedora"}}], - ) - vmi.deploy() -``` - -> **Note:** The `domain` parameter is **required** when creating a VMI programmatically. Omitting it raises `MissingRequiredArgumentError`. - -### VMI Constructor Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `domain` | `dict` | **Yes** | Domain spec (CPU, memory, devices) | -| `volumes` | `list` | No | Volumes to mount | -| `networks` | `list` | No | Network interfaces | -| `affinity` | `dict` | No | Scheduling affinity rules | -| `node_selector` | `dict` | No | Node label selector | -| `tolerations` | `list` | No | Toleration rules | -| `eviction_strategy` | `str` | No | `"LiveMigrate"`, `"LiveMigrateIfPossible"`, `"None"`, or `"External"` | -| `termination_grace_period_seconds` | `int` | No | Seconds to wait before force-killing | -| `hostname` | `str` | No | VM hostname | -| `priority_class_name` | `str` | No | Pod priority class | -| `scheduler_name` | `str` | No | Custom scheduler name | -| `start_strategy` | `str` | No | Set to `"Paused"` to start paused | -| `access_credentials` | `list` | No | Public keys to inject | -| `topology_spread_constraints` | `list` | No | Topology spread rules | - -### Waiting for a VMI to Run - -```python - vmi.wait_until_running(timeout=300) -``` - -If the VMI fails to start, `wait_until_running` raises `TimeoutExpiredError` and logs the virt-launcher pod status and logs for debugging. Set `logs=False` to suppress this: - -```python - vmi.wait_until_running(timeout=300, logs=False) -``` - -You can also specify a `stop_status` to abort early if the VMI enters a failure state: - -```python - vmi.wait_until_running(timeout=300, stop_status="Failed") -``` - -### Pausing and Unpausing - -```python - vmi.pause(wait=True) # Pause the VMI - vmi.unpause(wait=True) # Resume the VMI -``` - -### Resetting a VMI - -```python - vmi.reset() # Hard-reset the VMI (like pressing the reset button) -``` - -### Getting the VMI Node - -```python - node = vmi.get_node() - print(node.name) -``` - -### Network Interfaces - -```python - # All interfaces from the VMI status - interfaces = vmi.interfaces - - # Get IP address for a specific interface - ip = vmi.interface_ip("eth0") -``` - -### Guest Agent Information - -When the QEMU guest agent is installed in the VM: - -```python - vmi.guest_os_info # OS information - vmi.guest_fs_info # Filesystem information - vmi.guest_user_info # Logged-in user information - vmi.os_version # OS version string -``` - -> **Note:** If no guest agent is installed, `os_version` returns an empty dict and logs a warning. - -### Accessing the Virt-Launcher Pod - -```python - pod = vmi.get_virt_launcher_pod() - print(pod.name) - print(pod.status) - - # Access logs from the compute container - logs = pod.log(container="compute") -``` - -For clusters where a privileged client is required: - -```python - pod = vmi.get_virt_launcher_pod(privileged_client=admin_client) -``` - -### Accessing the Virt-Handler Pod - -```python - handler_pod = vmi.get_virt_handler_pod(privileged_client=admin_client) -``` - -### Executing Virsh Commands - -Run virsh commands inside the virt-launcher pod: - -```python - # Get the XML definition - xml_output = vmi.get_xml() - - # Get the XML as a parsed Python dict - xml_dict = vmi.get_xml_dict() - - # Get domain memory stats - memstats = vmi.get_dommemstat() - - # Run any arbitrary virsh command - output = vmi.execute_virsh_command(command="dominfo") -``` - -### Checking Root Status - -```python - pod = vmi.get_virt_launcher_pod() - - # Check if the virt-launcher pod runs as root - VirtualMachineInstance.is_pod_root(pod=pod) # True or False - - # Get the pod's runAsUser UID - VirtualMachineInstance.get_pod_user_uid(pod=pod) # int or None -``` - -## Live Migration - -Trigger a live migration by creating a `VirtualMachineInstanceMigration`: - -```python - from ocp_resources.virtual_machine_instance_migration import VirtualMachineInstanceMigration - - migration = VirtualMachineInstanceMigration( - name="migrate-my-vm", - namespace="my-namespace", - vmi_name="my-vm", - ) - migration.deploy() -``` - -Monitor migration by checking the VMI's status. Once migration completes, the VMI runs on the target node. See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for condition-based waiting. - -### Migration Resource Quotas - -Control resource allocation for migrations: - -```python - from ocp_resources.virtual_machine_migration_resource_quota import VirtualMachineMigrationResourceQuota - - quota = VirtualMachineMigrationResourceQuota( - name="migration-quota", - namespace="my-namespace", - requests_cpu="2", - requests_memory="4Gi", - limits_cpu="4", - limits_memory="8Gi", - ) - quota.deploy() -``` - -## Snapshots and Restores - -### Taking a Snapshot - -```python - from ocp_resources.virtual_machine_snapshot import VirtualMachineSnapshot - - snapshot = VirtualMachineSnapshot( - name="my-vm-snapshot", - namespace="my-namespace", - vm_name="my-vm", - ) - snapshot.deploy() - - # Wait for the snapshot to be ready - snapshot.wait_ready_to_use(timeout=300) - - # Wait for both snapshot readiness AND the VM's snapshotInProgress to clear - snapshot.wait_snapshot_done(timeout=300) -``` - -### Restoring from a Snapshot - -```python - from ocp_resources.virtual_machine_restore import VirtualMachineRestore - - restore = VirtualMachineRestore( - name="my-vm-restore", - namespace="my-namespace", - vm_name="my-vm", - snapshot_name="my-vm-snapshot", - ) - restore.deploy() - - # Wait for restore to complete - restore.wait_restore_done(timeout=300) -``` - -The `wait_restore_done()` method checks both the restore's `complete` status and that the VM's `restoreInProgress` field is cleared. - -| Parameter | Type | Description | -|---|---|---| -| `vm_name` | `str` | Target VirtualMachine name | -| `snapshot_name` | `str` | Source VirtualMachineSnapshot name | -| `volume_restore_policy` | `str` | Optional policy for volume restoration | - -## Cloning VMs - -```python - from ocp_resources.virtual_machine_clone import VirtualMachineClone - - clone = VirtualMachineClone( - name="clone-my-vm", - namespace="my-namespace", - source_name="my-vm", - target_name="my-vm-clone", - ) - clone.deploy() -``` - -> **Note:** `source_name` is **required**. If omitted, `MissingRequiredArgumentError` is raised. - -### Clone Parameters - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `source_name` | `str` | **Required** | Name of the source VM | -| `source_kind` | `str` | `"VirtualMachine"` | Kind of the source resource | -| `target_name` | `str` | Auto-generated | Name for the cloned VM | -| `label_filters` | `list` | `None` | Labels to copy, e.g. `["*", "!someKey/*"]` | -| `annotation_filters` | `list` | `None` | Annotations to copy | -| `new_mac_addresses` | `dict` | `None` | Override MACs: `{"nic0": "00:11:22:33:44:55"}` | -| `new_smbios_serial` | `str` | `None` | Override SMBIOS serial number | -| `volume_name_policy` | `str` | `None` | Volume naming policy for the clone | - -## Exporting VMs - -```python - from ocp_resources.virtual_machine_export import VirtualMachineExport - - export = VirtualMachineExport( - name="export-my-vm", - namespace="my-namespace", - source={"apiGroup": "kubevirt.io", "kind": "VirtualMachine", "name": "my-vm"}, - token_secret_ref="my-export-token", - ttl_duration="1h", - ) - export.deploy() -``` - -> **Note:** The `source` parameter is **required**. If omitted, `MissingRequiredArgumentError` is raised. - -## Advanced Usage - -### Instance Types and Preferences - -KubeVirt instance types define reusable VM sizing profiles. Preferences define non-sizing defaults (clock, firmware, devices). - -#### Namespaced Instance Type - -```python - from ocp_resources.virtual_machine_instancetype import VirtualMachineInstancetype - - instancetype = VirtualMachineInstancetype( - name="small", - namespace="my-namespace", - cpu={"guest": 2}, - memory={"guest": "4Gi"}, - ) - instancetype.deploy() -``` - -Both `cpu` and `memory` are **required**. Omitting either raises `MissingRequiredArgumentError`. - -#### Cluster-Scoped Instance Type - -```python - from ocp_resources.virtual_machine_cluster_instancetype import VirtualMachineClusterInstancetype - - cluster_instancetype = VirtualMachineClusterInstancetype( - name="large", - cpu={"guest": 8}, - memory={"guest": "16Gi"}, - gpus=[{"deviceName": "nvidia.com/A100", "name": "gpu0"}], - ) - cluster_instancetype.deploy() -``` - -#### Namespaced Preference - -```python - from ocp_resources.virtual_machine_preference import VirtualMachinePreference - - preference = VirtualMachinePreference( - name="linux-pref", - namespace="my-namespace", - cpu={"preferredCPUTopology": "preferSockets"}, - firmware={"preferredUseEfi": True}, - ) - preference.deploy() -``` - -#### Cluster-Scoped Preference - -```python - from ocp_resources.virtual_machine_cluster_preference import VirtualMachineClusterPreference - - cluster_preference = VirtualMachineClusterPreference( - name="windows-pref", - clock={"preferredTimer": {"hpet": {"present": False}}}, - features={"preferredHyperv": {"spinlocks": {"spinlocks": 8191}}}, - ) - cluster_preference.deploy() -``` - -### VMI Replica Sets - -Scale out VMIs horizontally: - -```python - from ocp_resources.virtual_machine_instance_replica_set import VirtualMachineInstanceReplicaSet - - replica_set = VirtualMachineInstanceReplicaSet( - name="my-vmi-rs", - namespace="my-namespace", - replicas=3, - selector={"matchLabels": {"app": "my-vmi"}}, - template={ - "metadata": {"labels": {"app": "my-vmi"}}, - "spec": { - "domain": { - "devices": {}, - "resources": {"requests": {"memory": "1Gi"}}, - } - }, - }, - ) - replica_set.deploy() -``` - -Both `selector` and `template` are **required**. - -### Storage Migration - -Migrate VM storage between storage classes: - -```python - from ocp_resources.virtual_machine_storage_migration_plan import VirtualMachineStorageMigrationPlan - from ocp_resources.virtual_machine_storage_migration import VirtualMachineStorageMigration - - # Create a migration plan - plan = VirtualMachineStorageMigrationPlan( - name="migrate-storage", - namespace="my-namespace", - virtual_machines=[{"name": "my-vm", "volumes": [{"name": "rootdisk", "target": {"storageClassName": "fast-ssd"}}]}], - retention_policy="deleteSource", - ) - plan.deploy() - - # Execute the migration - migration = VirtualMachineStorageMigration( - name="migrate-storage-exec", - namespace="my-namespace", - virtual_machine_storage_migration_plan_ref={"name": "migrate-storage", "namespace": "my-namespace"}, - ) - migration.deploy() -``` - -### VM Templates - -```python - from ocp_resources.virtual_machine_template import VirtualMachineTemplate - - template = VirtualMachineTemplate( - name="fedora-template", - namespace="my-namespace", - virtual_machine={ - "spec": { - "template": { - "spec": { - "domain": { - "devices": {}, - "resources": {"requests": {"memory": "2Gi"}}, - } - } - } - } - }, - parameters=[{"name": "VM_NAME", "description": "Name of the VM", "required": True}], - message="Use this template to create Fedora VMs.", - ) - template.deploy() -``` - -### Listing VMs Across Namespaces - -```python - # List all VMs in a namespace - for vm in VirtualMachine.get(namespace="my-namespace"): - print(f"{vm.name}: {vm.instance.get('status', {}).get('printableStatus')}") - - # List with label selectors - for vm in VirtualMachine.get( - namespace="my-namespace", - label_selector="app=webserver", - ): - print(vm.name) -``` - -See [Querying and Watching Resources](querying-resources.html) for more query options. - -### Getting VM Network Interfaces - -```python - interfaces = vm.get_interfaces() - for iface in interfaces: - print(f"Name: {iface['name']}, Model: {iface.get('model', 'default')}") -``` - -## Troubleshooting - -### VM Stuck in "Starting" Status - -Check the VMI's virt-launcher pod for scheduling or image-pull issues: - -```python - vmi = vm.vmi - try: - pod = vmi.get_virt_launcher_pod() - print(f"Pod status: {pod.status}") - print(pod.log(container="compute")) - except ResourceNotFoundError: - print("Virt-launcher pod not found — VM may not have been scheduled yet") -``` - -### TimeoutExpiredError on start/stop - -Increase the timeout parameter: - -```python - vm.start(timeout=600, wait=True) # 10 minutes instead of default 4 -``` - -### MissingRequiredArgumentError - -This is raised when a required constructor parameter is missing. Common cases: - -| Resource | Required Parameters | -|---|---| -| `VirtualMachineInstance` | `domain` | -| `VirtualMachineInstancetype` | `cpu`, `memory` | -| `VirtualMachineClone` | `source_name` | -| `VirtualMachineExport` | `source` | -| `VirtualMachineInstanceReplicaSet` | `selector`, `template` | -| `VirtualMachineStorageMigration` | `virtual_machine_storage_migration_plan_ref` | -| `VirtualMachineTemplate` | `virtual_machine` | - -### Guest Agent Not Reporting - -If `vmi.os_version` returns an empty dict, the QEMU guest agent is not installed or not running inside the VM. Install and start `qemu-guest-agent` in the guest OS. - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Querying and Watching Resources](querying-resources.html) -- [Common Resource Patterns](common-patterns.html) -- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) - - ---- - -Source: testing-without-cluster.md - -# Testing Without a Cluster Using the Fake Client - -Write unit tests that create, read, update, and delete Kubernetes resources — including status checks, watch events, and custom CRDs — without connecting to a real cluster. - -## Prerequisites - -- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html)) -- `pytest` installed for running tests - -## Quick Example - -```python - import pytest - from ocp_resources.resource import get_client - from ocp_resources.namespace import Namespace - - - @pytest.fixture(scope="class") - def fake_client(): - return get_client(fake=True) - - - class TestMyFeature: - def test_create_namespace(self, fake_client): - ns = Namespace(client=fake_client, name="my-namespace") - ns.deploy() - - assert ns.exists - assert ns.kind == "Namespace" - assert ns.status == Namespace.Status.ACTIVE - - ns.clean_up(wait=False) - assert not ns.exists -``` - -Run it with `pytest` — no cluster, no kubeconfig, no network calls. - -## How It Works - -Calling `get_client(fake=True)` returns an in-memory client that simulates the Kubernetes API. It supports all standard resources (Pods, Deployments, Services, Namespaces, etc.) and OpenShift resources (Routes, Projects, etc.) out of the box. - -All resource classes (`Pod`, `Deployment`, `Namespace`, etc.) accept this fake client through their `client` parameter, and all CRUD methods work identically to a real cluster. - -## Step-by-Step: CRUD Operations - -### 1. Set up a shared fixture - -Create a `conftest.py` in your test directory: - -```python - import pytest - from ocp_resources.resource import get_client - - - @pytest.fixture(scope="class") - def fake_client(): - """Provides a fake Kubernetes client for all tests.""" - return get_client(fake=True) -``` - -> **Tip:** Use `scope="class"` so resources created in one test method persist for subsequent methods in the same class. - -### 2. Create a resource - -```python - from ocp_resources.pod import Pod - - - def test_create_pod(fake_client): - pod = Pod( - client=fake_client, - name="my-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - result = pod.deploy() - - assert result.name == "my-pod" - assert pod.exists -``` - -The fake client automatically generates metadata (`uid`, `resourceVersion`, `creationTimestamp`) and a realistic `status` section for the resource. - -### 3. Read and query resources - -```python - from ocp_resources.namespace import Namespace - - - def test_list_resources(fake_client): - # Create some namespaces first - Namespace(client=fake_client, name="ns-a").deploy() - Namespace(client=fake_client, name="ns-b").deploy() - - # Iterate over all Namespace resources - for ns in Namespace.get(client=fake_client): - assert ns.name -``` - -Access individual resource data via the `instance` property: - -```python - def test_get_instance(fake_client): - ns = Namespace(client=fake_client, name="test-ns") - ns.deploy() - - assert ns.instance - assert ns.kind == "Namespace" -``` - -### 4. Update a resource - -Use `update()` to patch a resource or `update_replace()` to replace it entirely: - -```python - def test_update_labels(fake_client): - ns = Namespace(client=fake_client, name="labeled-ns") - ns.deploy() - - # Patch: add a label - resource_dict = ns.instance.to_dict() - resource_dict["metadata"]["labels"]["env"] = "staging" - ns.update(resource_dict=resource_dict) - - assert ns.labels["env"] == "staging" -``` - -```python - def test_replace_labels(fake_client): - ns = Namespace(client=fake_client, name="replace-ns") - ns.deploy() - - resource_dict = ns.instance.to_dict() - resource_dict["metadata"]["labels"] = {"new-label": "value"} - ns.update_replace(resource_dict=resource_dict) - - assert "new-label" in ns.labels.keys() -``` - -### 5. Delete a resource - -```python - def test_delete_resource(fake_client): - ns = Namespace(client=fake_client, name="temp-ns") - ns.deploy() - assert ns.exists - - ns.clean_up(wait=False) - assert not ns.exists -``` - -### 6. Use context managers for automatic cleanup - -Resources support Python's `with` statement. The resource deploys on entry and is cleaned up on exit: - -```python - from ocp_resources.secret import Secret - - - def test_context_manager(fake_client): - with Secret(name="my-secret", namespace="default", client=fake_client) as sec: - assert sec.exists - - # Automatically cleaned up - assert not sec.exists -``` - -## Testing Status and Conditions - -The fake client generates realistic status objects so you can test code that reads resource status. - -### Checking resource status - -```python - def test_namespace_status(fake_client): - ns = Namespace(client=fake_client, name="status-ns") - ns.deploy() - - assert ns.status == Namespace.Status.ACTIVE -``` - -### Waiting for conditions - -```python - from ocp_resources.pod import Pod - - - def test_wait_for_condition(fake_client): - pod = Pod( - client=fake_client, - name="ready-pod", - namespace="default", - containers=[{"name": "app", "image": "nginx:latest"}], - ) - pod.deploy() - - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=5) -``` - -### Simulating "not ready" resources - -Control a resource's readiness state with the `fake-client.io/ready` annotation: - -```python - def test_not_ready_pod(fake_client): - pod = Pod( - client=fake_client, - name="failing-pod", - namespace="default", - containers=[{"name": "app", "image": "nginx:latest"}], - annotations={"fake-client.io/ready": "false"}, - ) - pod.deploy() - - # The pod will have Ready condition set to False - pod.wait_for_condition( - condition=pod.Condition.READY, - status=pod.Condition.Status.FALSE, - timeout=5, - ) - - message = pod.get_condition_message( - condition_type=pod.Condition.READY, - condition_status=pod.Condition.Status.FALSE, - ) - assert message -``` - -Resource-specific status behavior when marked "not ready": - -| Resource | Ready behavior | Not-ready behavior | -|---|---|---| -| Pod | `phase: Running`, containers ready | Containers in `waiting` state | -| Deployment | `readyReplicas` matches `spec.replicas` | `readyReplicas: 0`, `unavailableReplicas` set | -| Namespace | `phase: Active` | `phase: Terminating` | -| All others | `Ready` condition `True` | `Ready` condition `False` | - -## Testing with Events - -The fake client automatically generates Kubernetes events for create, update, and delete operations. You can query them through the resource's `events()` method: - -```python - def test_resource_events(fake_client): - pod = Pod( - client=fake_client, - name="event-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - pod.deploy() - - events = list(pod.events(timeout=1)) - assert events -``` - -## Testing with Deployments - -Deployments get a full status template including replica counts, conditions, and observed generation: - -```python - from ocp_resources.deployment import Deployment - - - def test_deployment(fake_client): - dep = Deployment( - client=fake_client, - name="my-deploy", - namespace="default", - replicas=3, - selector={"matchLabels": {"app": "web"}}, - template={ - "metadata": {"labels": {"app": "web"}}, - "spec": {"containers": [{"name": "web", "image": "nginx:latest"}]}, - }, - ) - dep.deploy() - - assert dep.exists - assert dep.kind == "Deployment" - - dep.clean_up(wait=False) - assert not dep.exists -``` - -## Testing with ResourceList - -Test bulk resource creation with `ResourceList` and `NamespacedResourceList`: - -```python - from ocp_resources.resource import ResourceList, NamespacedResourceList - - - def test_resource_list(fake_client): - with ResourceList( - client=fake_client, - resource_class=Namespace, - name="batch-ns", - num_resources=3, - ) as namespaces: - assert len(namespaces) == 3 - # All three namespaces are automatically cleaned up -``` - -See [Managing Bulk Resources with ResourceList](managing-resource-lists.html) for more details. - -## Advanced Usage - -### Using the low-level API directly - -You can bypass resource classes and work with the fake client's API directly — useful for testing custom logic: - -```python - def test_low_level_api(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Pod") - - # Create - pod = api.create( - body={ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "raw-pod", "namespace": "default"}, - "spec": {"containers": [{"name": "app", "image": "nginx:latest"}]}, - }, - namespace="default", - ) - assert pod.metadata.name == "raw-pod" - assert pod.status.phase == "Running" - - # List - pods = api.get(namespace="default") - assert len(pods.items) >= 1 - - # Patch - updated = api.patch( - name="raw-pod", - namespace="default", - body={"metadata": {"labels": {"env": "test"}}}, - ) - assert updated.metadata.labels["env"] == "test" - - # Delete - api.delete(name="raw-pod", namespace="default") -``` - -### Label and field selectors - -The fake client supports filtering resources by labels and fields: - -```python - def test_label_selector(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Pod") - - api.create( - body={ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "pod-a", "namespace": "default", "labels": {"app": "web"}}, - "spec": {"containers": [{"name": "c", "image": "nginx"}]}, - }, - namespace="default", - ) - - api.create( - body={ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "pod-b", "namespace": "default", "labels": {"app": "api"}}, - "spec": {"containers": [{"name": "c", "image": "nginx"}]}, - }, - namespace="default", - ) - - # Filter by label - web_pods = api.get(namespace="default", label_selector="app=web") - assert len(web_pods.items) == 1 - assert web_pods.items[0].metadata.name == "pod-a" -``` - -Field selectors support `metadata.name` and `metadata.namespace`: - -```python - def test_field_selector(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Pod") - result = api.get(namespace="default", field_selector="metadata.name=my-pod") -``` - -### Watch for resource changes - -The `watch()` method yields existing resources as `ADDED` events: - -```python - def test_watch(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Pod") - - api.create( - body={ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "watch-pod", "namespace": "default"}, - "spec": {"containers": [{"name": "c", "image": "nginx"}]}, - }, - namespace="default", - ) - - for event in api.watch(namespace="default"): - assert event["type"] == "ADDED" - assert event["object"].metadata.name - break # Stop after first event -``` - -### Registering Custom Resource Definitions - -To test with CRDs not bundled in the default schema, register them on the client: - -```python - def test_custom_resource(fake_client): - # Register the CRD - fake_client.register_resources({ - "kind": "MyApp", - "api_version": "v1alpha1", - "group": "apps.example.com", - "namespaced": True, - }) - - # Use it through the low-level API - api = fake_client.resources.get( - api_version="apps.example.com/v1alpha1", - kind="MyApp", - ) - - created = api.create( - body={ - "apiVersion": "apps.example.com/v1alpha1", - "kind": "MyApp", - "metadata": {"name": "my-app", "namespace": "default"}, - "spec": {"replicas": 2}, - }, - namespace="default", - ) - assert created.metadata.name == "my-app" -``` - -You can also register multiple CRDs at once: - -```python - fake_client.register_resources([ - {"kind": "MyApp", "api_version": "v1", "group": "apps.example.com"}, - {"kind": "MyConfig", "api_version": "v1beta1", "group": "config.example.com", "namespaced": False}, - ]) -``` - -### Testing incremental workflows - -Use `@pytest.mark.incremental` to test a resource through its full lifecycle. Each test depends on the previous one succeeding: - -```python - import pytest - from ocp_resources.pod import Pod - - - @pytest.mark.incremental - class TestPodLifecycle: - @pytest.fixture(scope="class") - def pod(self, fake_client): - return Pod( - client=fake_client, - name="lifecycle-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) - - def test_01_create(self, pod): - deployed = pod.deploy() - assert deployed.name == "lifecycle-pod" - assert pod.exists - - def test_02_read(self, pod): - assert pod.instance - assert pod.kind == "Pod" - - def test_03_update(self, pod): - resource_dict = pod.instance.to_dict() - resource_dict["metadata"]["labels"] = {"updated": "true"} - pod.update(resource_dict=resource_dict) - assert pod.labels["updated"] == "true" - - def test_04_delete(self, pod): - pod.clean_up(wait=False) - assert not pod.exists -``` - -> **Note:** The `@pytest.mark.incremental` marker requires custom `conftest.py` hooks. Add the following to your `conftest.py`: -> ```python -> def pytest_runtest_makereport(item, call): -> if call.excinfo is not None and "incremental" in item.keywords: -> parent = item.parent -> parent._previousfailed = item -> -> def pytest_runtest_setup(item): -> if "incremental" in item.keywords: -> previousfailed = getattr(item.parent, "_previousfailed", None) -> if previousfailed is not None: -> pytest.xfail(f"previous test failed ({previousfailed.name})") -> ``` - -### Conflict detection - -The fake client raises errors for duplicate creates and missing resources, just like a real cluster: - -```python - from fake_kubernetes_client import NotFoundError, ConflictError - - - def test_conflict_on_duplicate_create(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Namespace") - - api.create(body={"metadata": {"name": "unique-ns"}}) - - with pytest.raises(ConflictError): - api.create(body={"metadata": {"name": "unique-ns"}}) - - - def test_not_found_on_missing(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Pod") - - with pytest.raises(NotFoundError): - api.get(name="does-not-exist", namespace="default") -``` - -### Accessing response data - -The fake client returns response objects that support both attribute access and dictionary access: - -```python - def test_response_access(fake_client): - api = fake_client.resources.get(api_version="v1", kind="Pod") - - created = api.create( - body={ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "access-pod", "namespace": "default"}, - "spec": {"containers": [{"name": "c", "image": "nginx"}]}, - }, - namespace="default", - ) - - # Attribute access - assert created.metadata.name == "access-pod" - assert created.status.phase == "Running" - - # Dictionary-style access - assert created["metadata"]["name"] == "access-pod" - - # Convert to a plain dict - data = created.to_dict() - assert isinstance(data, dict) - assert data["metadata"]["name"] == "access-pod" -``` - -## Troubleshooting - -**`ResourceNotFoundError` when creating a resource** - -The fake client must recognize the resource's API group and kind. Standard Kubernetes and OpenShift resources are pre-registered. For CRDs, call `fake_client.register_resources(...)` before using them. - -**Status fields are missing or empty** - -Pods, Deployments, Services, and Namespaces have hardcoded status templates. Other resources get a generic status with a `Ready` condition. If the resource has a schema in the bundled OpenAPI definitions, a richer status is generated automatically. - -**Tests interfere with each other** - -Each call to `get_client(fake=True)` creates an independent in-memory store. Use separate fixtures or narrower fixture scopes to isolate tests that share a client. - -## Related Pages - -- [Connecting to Clusters](connecting-to-clusters.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Querying and Watching Resources](querying-resources.html) -- [Managing Bulk Resources with ResourceList](managing-resource-lists.html) -- [Common Resource Patterns](common-patterns.html) - - ---- - -Source: generating-resource-classes.md - -# Generating New Resource Classes with class-generator - -Scaffold Python wrapper classes for any Kubernetes or OpenShift custom resource definition (CRD) so you can manage those resources using the same patterns as built-in resources. The `class-generator` CLI introspects your cluster's OpenAPI schemas and produces ready-to-use Python files complete with typed constructors, docstrings, and `to_dict()` serialization. - -## Prerequisites - -- **openshift-python-wrapper** installed (see [Installing and Creating Your First Resource](quickstart.html)) -- `oc` or `kubectl` CLI available on your `PATH` -- An active connection to a Kubernetes or OpenShift cluster with admin privileges (for `--kind` generation and schema updates) - -## Quick Example - -Generate a wrapper class for the `Deployment` resource: - -```bash - class-generator -k Deployment -``` - -This creates a Python file in `ocp_resources/` with a fully typed class you can import and use immediately: - -```python - from ocp_resources.deployment import Deployment - - dep = Deployment( - name="my-app", - namespace="default", - replicas=3, - selector={"matchLabels": {"app": "my-app"}}, - template={...}, - ) - dep.deploy() -``` - -## Step-by-Step: Generating a Resource Class - -### 1. Generate the class file - -Pass the CRD's `Kind` (case-sensitive) to `--kind` / `-k`: - -```bash - class-generator -k StorageCluster -``` - -The output file is automatically named using snake_case — `ocp_resources/storage_cluster.py`. - -### 2. Preview without writing (dry run) - -See exactly what would be generated without touching the filesystem: - -```bash - class-generator -k StorageCluster --dry-run -``` - -The rendered Python code is printed to the console with syntax highlighting. - -### 3. Review the generated file - -Every generated file contains: - -- A class inheriting from `Resource` (cluster-scoped) or `NamespacedResource` (namespace-scoped) -- Typed `__init__` parameters extracted from the OpenAPI spec and `status` fields -- A `to_dict()` method that serializes required and optional fields -- A `# End of generated code` marker — any code you add **below** this line is preserved on regeneration - -Example generated output for `ConfigMap`: - -```python - from typing import Any - from ocp_resources.resource import NamespacedResource - - - class ConfigMap(NamespacedResource): - """ - ConfigMap holds configuration data for pods to consume. - """ - - api_version: str = NamespacedResource.ApiVersion.V1 - - def __init__( - self, - binary_data: dict[str, Any] | None = None, - data: dict[str, Any] | None = None, - immutable: bool | None = None, - **kwargs: Any, - ) -> None: ... -``` - -### 4. Generate multiple kinds at once - -Pass a comma-separated list (no spaces): - -```bash - class-generator -k Pod,Service,ConfigMap -``` - -Multiple kinds are generated in parallel for speed. - -### 5. Overwrite an existing file - -By default, existing files are not overwritten. Pass `--overwrite` to replace them: - -```bash - class-generator -k Deployment --overwrite -``` - -To create a timestamped backup before overwriting: - -```bash - class-generator -k Deployment --overwrite --backup -``` - -Backups are stored in `.backups/backup-YYYYMMDD-HHMMSS/` preserving the original directory structure. - -### 6. Write to a custom output path - -```bash - class-generator -k MyCustomResource -o my_package/custom_resource.py -``` - -## Handling Duplicate Kinds - -Some Kubernetes kinds exist in multiple API groups. For example, `DNS` exists in both `config.openshift.io` and `operator.openshift.io`. When this happens, the generator automatically creates separate files with group-based suffixes: - -| Kind | API Group | Generated File | -|------|-----------|----------------| -| `DNS` | `config.openshift.io` | `dns_config_openshift_io.py` | -| `DNS` | `operator.openshift.io` | `dns_operator_openshift_io.py` | - -Both files define a class named `DNS` but with different `api_group` attributes. Import the one matching the API group you need. - -## Adding User Code to Generated Files - -Any code you add **below** the `# End of generated code` marker is preserved when you regenerate the file with `--overwrite`. This is how built-in resources like `ConfigMap` include custom properties: - -```python - # End of generated code - - - @property - def keys_to_hash(self): - return ["data", "binaryData"] -``` - -Custom imports you add at the top of the file are also preserved during regeneration. - -## Updating Schemas - -The generator uses a local schema cache to resolve resource definitions. If a CRD is missing (e.g., you just installed a new operator), update the cache first. - -### Full schema update - -Fetch all schemas from the connected cluster: - -```bash - class-generator --update-schema -``` - -> **Note:** If connected to an older cluster, existing schemas are preserved. Only new or missing resources are added. - -### Single-resource schema update - -Update just one resource without touching the rest: - -```bash - class-generator --update-schema-for LlamaStackDistribution -``` - -Then generate the class: - -```bash - class-generator -k LlamaStackDistribution --overwrite -``` - -> **Warning:** `--update-schema` and `--update-schema-for` are mutually exclusive. Use one or the other. - -## Verifying Generated Files - -A generated file is only trustworthy while it still matches what class-generator produces. -`--verify*` checks that without modifying anything: on drift it exits `1`, lists each file -with its drift classification, and prints the regenerate command. - -```bash - # every generated resource - class-generator --verify-all - - # only the files this branch changed (compares against the branch's base, merge-base aware) - class-generator --verify-changed - - # what a PR touched, is what CI should check - tox -e validate-generated -``` - -Narrow the report when you only care about a specific kind of drift: - -```bash - # only files whose drift is blank lines and/or added parameters, nothing else - class-generator --verify-all --classification "blank lines,signature code add" --match - - # everything that is safe to regenerate - drops caller-breaking drift - class-generator --verify-all --exclude "breaking change,schema incomplete" -``` - -`breaking change` covers a removed parameter, a parameter that became required, a parameter -emitted under a different API key, a changed base class, and a changed -`api_group`/`api_version`. `schema incomplete` marks a class rendered without any -parameters because a schema `$ref` could not be resolved — fix the schema, not the file. - -See the [class-generator CLI reference](class-generator-cli.html#verification) for every -option and classification. - -## Discovering Missing Resources - -Find resources on your cluster that don't have wrapper classes yet: - -```bash - class-generator --discover-missing -``` - -### Coverage report - -Get a summary of how many resources have wrapper classes: - -```bash - class-generator --coverage-report -``` - -Output in JSON for CI/CD pipelines: - -```bash - class-generator --coverage-report --json -``` - -### Auto-generate all missing resources - -Update schemas and generate classes for every resource that's missing: - -```bash - class-generator --update-schema --generate-missing -``` - -> **Tip:** Use `--dry-run` with `--generate-missing` to preview what would be generated without writing any files. - -## Advanced Usage - -### Batch regeneration - -Regenerate all existing generated resource classes to pick up schema changes: - -```bash - class-generator --regenerate-all -``` - -With backups: - -```bash - class-generator --regenerate-all --backup -``` - -Filter to a subset of resources using glob patterns: - -```bash - class-generator --regenerate-all --filter "Pod*" - class-generator --regenerate-all --filter "*Service" -``` - -### Adding tests - -Generate test files alongside the resource class: - -```bash - class-generator -k Pod --add-tests -``` - -This creates test manifests and runs them automatically with `pytest`. Tests are placed under the test manifests directory and validate that the generated class can be parsed correctly. - -### Shell completion - -Add this to your `~/.bashrc` or `~/.zshrc` for tab completion: - -```bash - # For zsh - if type class-generator > /dev/null; then eval "$(_CLASS_GENERATOR_COMPLETE=zsh_source class-generator)"; fi - - # For bash - if type class-generator > /dev/null; then eval "$(_CLASS_GENERATOR_COMPLETE=bash_source class-generator)"; fi -``` - -### Verbose output - -Enable debug logging to see exactly what the generator is doing: - -```bash - class-generator -k Deployment -v -``` - -### When a kind is missing from the schema - -If the kind you request is not in the local schema cache, the CLI prompts you: - -``` - deployment not found in schema mapping, Do you want to run --update-schema and retry? [Y/N]: -``` - -Answering `y` triggers a schema update and retries generation automatically. - -> **Note:** The prompt only appears in interactive mode. When generating multiple kinds or running in batch mode, missing kinds are skipped with a warning. Run `--update-schema` separately beforehand in CI environments. - -### API group and version warnings - -If a generated resource uses an API group or version that hasn't been registered in the base `Resource` class, the generator logs a warning like: - -``` - Missing API Group in Resource - Please add `Resource.ApiGroup.MY_GROUP_IO = my-group.io` manually into - ocp_resources/resource.py under Resource class > ApiGroup class. -``` - -Follow the instructions to register the API group so the resource can connect to the correct API endpoint. See [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) for details on how `ApiGroup` and `ApiVersion` are used. - -## CLI Options Reference - -| Option | Description | -|--------|-------------| -| `-k`, `--kind` | Kind(s) to generate (comma-separated) | -| `-o`, `--output-file` | Custom output file path | -| `--overwrite` | Overwrite existing files | -| `--dry-run` | Preview output without writing files | -| `--add-tests` | Generate test files for the kind | -| `--update-schema` | Update all schema files from cluster | -| `--update-schema-for` | Update schema for a single kind | -| `--discover-missing` | Find resources without wrapper classes | -| `--coverage-report` | Show coverage statistics | -| `--json` | Output reports in JSON format | -| `--generate-missing` | Generate classes for all missing resources | -| `--regenerate-all` | Regenerate all existing generated classes | -| `--backup` | Create backup before overwriting/regenerating | -| `--filter` | Glob pattern to filter `--regenerate-all` | -| `-v`, `--verbose` | Enable debug logging | - -For the full CLI reference, see [class-generator CLI Reference](class-generator-cli.html). - -## Troubleshooting - -**"Neither 'oc' nor 'kubectl' binary found in PATH"** -Install `oc` from the [OpenShift mirror](https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/stable/) or `kubectl` from the [Kubernetes docs](https://kubernetes.io/docs/tasks/tools/). Ensure the binary is on your `PATH`. - -**"Resource kind 'X' not found"** -The kind is not in the local schema cache. Run `class-generator --update-schema` while connected to a cluster that has the CRD installed, then retry. - -**Generated filename looks wrong (e.g., `c_d_i_config.py`)** -Single-letter segments in snake_case names indicate the camelCase-to-snake_case converter doesn't recognize an acronym. The generator raises an error with instructions to add the acronym to the converter. - -**"Failed to fetch OpenAPI v3 index"** -Ensure you are logged in to the cluster with sufficient privileges. The generator needs to access the `/openapi/v3` endpoint, which typically requires cluster-admin access. - -## Related Pages - -- [class-generator CLI Reference](class-generator-cli.html) -- [Schema Validation and Code Generation Architecture](schema-validation-internals.html) -- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) -- [Resource and NamespacedResource API](resource-api.html) -- [Validating Resources Against OpenAPI Schemas](validating-resources.html) - - ---- - -Source: class-generator-cli.md - -# class-generator CLI Reference - -The `class-generator` command generates Python wrapper classes for Kubernetes and OpenShift resources. It is installed as a console script entry point from the `openshift-python-wrapper` package. - -``` - class-generator = "class_generator.cli:main" -``` - -```bash - pip install openshift-python-wrapper - # or - uv tool install openshift-python-wrapper -``` - -> **Tip:** Enable shell completion by adding to `~/.bashrc` or `~/.zshrc`: -> ```bash -> if type class-generator > /dev/null; then eval "$(_CLASS_GENERATOR_COMPLETE=zsh_source class-generator)"; fi -> ``` - -## Synopsis - -``` - class-generator [OPTIONS] -``` - -At least one action option must be specified: `--kind`, `--update-schema`, `--update-schema-for`, `--discover-missing`, `--coverage-report`, `--generate-missing`, or `--regenerate-all`. - ---- - -## Options Reference - -### Kind Generation - -#### `-k`, `--kind` - -| Property | Value | -|----------|-------| -| **Type** | `STRING` | -| **Default** | `None` | -| **Required** | No (but at least one action must be specified) | -| **Requires** | Connected cluster with admin privileges | - -Generate Python wrapper classes for the specified Kubernetes resource Kind(s). Multiple kinds can be comma-separated (no spaces). - -When a kind is not found in the local schema mapping file, the CLI interactively prompts to run `--update-schema` (only in interactive CLI mode). - -```bash - # Single kind - class-generator -k Pod - - # Multiple kinds (processed in parallel) - class-generator -k Deployment,Pod,ConfigMap -``` - -When multiple kinds share the same Kind name but belong to different API groups (e.g., `DNS` from `config.openshift.io` and `operator.openshift.io`), separate files are generated with API group suffixes: - -``` - dns_config_openshift_io.py - dns_operator_openshift_io.py -``` - ---- - -#### `-o`, `--output-file` - -| Property | Value | -|----------|-------| -| **Type** | `PATH` | -| **Default** | `ocp_resources/.py` | -| **Required** | No | - -Override the output file path for the generated Python module. If not provided, the filename is derived from the Kind using `convert_camel_case_to_snake_case`. - -```bash - class-generator -k Pod -o my_custom_pod.py -``` - -> **Note:** When generating multiple comma-separated kinds, the `--output-file` value applies to all kinds. For independent output paths, run separate commands. - ---- - -#### `--overwrite` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Overwrite an existing output file. Without this flag, if the target file already exists, a `_TEMP.py` suffixed file is created instead. - -When overwriting, any user-added code blocks (code after the `# End of generated code` marker) and user imports are preserved in the regenerated file. - -```bash - class-generator -k Pod --overwrite -``` - ---- - -#### `--dry-run` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Preview the generated output without writing any files. The generated Python code is printed to the console with syntax highlighting and line numbers using Rich. - -```bash - class-generator -k Pod --dry-run -``` - ---- - -#### `--backup` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | -| **Requires** | `--regenerate-all` or `--overwrite` | - -Create a timestamped backup of existing files before overwriting or regenerating. Backups are stored in `.backups/backup-YYYYMMDD-HHMMSS/` preserving the original directory structure. - -```bash - class-generator -k Pod --overwrite --backup -``` - -> **Note:** Using `--backup` without either `--regenerate-all` or `--overwrite` results in a constraint error. - ---- - -### Test Generation - -#### `--add-tests` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | -| **Requires** | `-k`/`--kind` | - -Generate test files for the specified Kind and run them. This performs two actions: - -1. Generates a test manifest in `class_generator/tests/manifests//` and regenerates `class_generator/tests/test_class_generator.py` from the Jinja2 template. -2. Runs the generated test file using `uv run --group tests pytest class_generator/tests/test_class_generator.py`. - -```bash - class-generator -k Pod --add-tests -``` - -> **Warning:** `--add-tests` cannot be used without `-k`/`--kind`. Running `class-generator --add-tests` alone exits with a non-zero status. - ---- - -### Schema Management - -#### `--update-schema` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | -| **Requires** | Connected cluster; `oc` or `kubectl` in PATH | -| **Mutually exclusive with** | `--update-schema-for` | - -Fetch all resource schemas from the connected cluster's OpenAPI v3 endpoints and update the local schema files: - -- `class_generator/schema/__resources-mappings.json` (compressed as `.json.gz`) -- `class_generator/schema/_definitions.json` - -The update strategy depends on the cluster version: - -| Cluster Version | Behavior | -|-----------------|----------| -| Same or newer than last update | Full update — fetches all schemas, updates existing resources | -| Older than last update | Incremental — only adds missing resources, preserves existing schemas | - -When used alone, exits after updating. When combined with `--generate-missing`, continues to resource generation after the update. - -```bash - # Update schema only - class-generator --update-schema - - # Update schema then generate missing resources - class-generator --update-schema --generate-missing -``` - -> **Note:** When `--update-schema` is used without `--generate-missing`, it cannot be combined with `-k`, `--discover-missing`, `--coverage-report`, `--dry-run`, `--overwrite`, `-o`, `--add-tests`, or `--regenerate-all`. - ---- - -#### `--update-schema-for` - -| Property | Value | -|----------|-------| -| **Type** | `STRING` | -| **Default** | `None` | -| **Requires** | Connected cluster; `oc` or `kubectl` in PATH | -| **Mutually exclusive with** | `--update-schema` | - -Update the schema for a single resource Kind without affecting other resources. The Kind name is **case-sensitive**. - -This fetches only the API paths relevant to the specified Kind, updates (or adds) its schema in the mapping file, and exits. - -```bash - class-generator --update-schema-for LlamaStackDistribution -``` - -Use cases: -- Connected to an older cluster but need to update a specific CRD -- A new operator was installed and you need its resource schema -- Refreshing just one resource without a full schema update - -After updating, regenerate the class: - -```bash - class-generator --update-schema-for LlamaStackDistribution - class-generator -k LlamaStackDistribution --overwrite -``` - -**Errors:** - -| Error | Cause | -|-------|-------| -| `ResourceNotFoundError` | The Kind is not found on the cluster (CRD not installed or name misspelled) | -| `RuntimeError` | API paths not found or schema extraction failed | - -> **Note:** Cannot be combined with `-k`, `--discover-missing`, `--coverage-report`, `--generate-missing`, or `--regenerate-all`. - ---- - -### Coverage and Discovery - -#### `--discover-missing` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Analyze resource coverage by comparing schema-mapped resources against implemented wrapper classes in `ocp_resources/`. Generates a console report showing coverage statistics and missing resources. - -```bash - class-generator --discover-missing -``` - ---- - -#### `--coverage-report` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Generate a detailed coverage report showing: - -| Metric | Description | -|--------|-------------| -| Total Resources in Schema | Number of resource Kinds in the schema mapping | -| Auto-Generated Resources | Wrapper classes with the generated marker | -| Coverage | Percentage of mapped resources with generated classes | -| Missing (Not Generated) | Resources in schema but without generated classes | -| Manual Implementations | Resource classes without the generated marker | - -```bash - # Console output (default) - class-generator --coverage-report - - # JSON output - class-generator --coverage-report --json -``` - ---- - -#### `--json` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Output reports in JSON format instead of Rich console tables. Applies to `--coverage-report`, `--discover-missing`, and `--generate-missing`. - -The JSON output structure: - -```json - { - "generated_resources": ["ConfigMap", "Deployment", "Pod"], - "manual_resources": ["VirtualMachine"], - "missing_resources": [{"kind": "Binding"}, {"kind": "ComponentStatus"}], - "coverage_stats": { - "total_in_mapping": 400, - "total_generated": 197, - "total_manual": 25, - "coverage_percentage": 49.25, - "missing_count": 203 - } - } -``` - -```bash - class-generator --coverage-report --json -``` - ---- - -#### `--generate-missing` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Generate wrapper classes for all resources found in the schema mapping that do not yet have generated files. Each missing resource Kind is passed to `class_generator()` individually. - -Can be combined with `--update-schema` to first refresh the schema, then generate all missing classes. - -```bash - # Generate missing resources - class-generator --generate-missing - - # Update schema first, then generate missing - class-generator --update-schema --generate-missing - - # Dry run to preview - class-generator --generate-missing --dry-run -``` - ---- - -### Batch Regeneration - -#### `--regenerate-all` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Regenerate all existing generated resource classes using the latest schemas. Only files containing the `# Generated using` marker in `ocp_resources/` are processed. - -Regeneration runs in parallel (up to 10 workers). User-added code (below `# End of generated code`) is preserved during regeneration. - -```bash - # Regenerate all resources - class-generator --regenerate-all - - # With backup - class-generator --regenerate-all --backup - - # Dry run - class-generator --regenerate-all --dry-run - - # Filter to specific resources - class-generator --regenerate-all --filter "Pod*" -``` - -Output summary: - -``` - Regeneration complete: 195 succeeded, 2 failed - Backup files stored in: .backups/backup-20260705-143022 -``` - ---- - -#### `--filter` - -| Property | Value | -|----------|-------| -| **Type** | `STRING` | -| **Default** | `None` | -| **Requires** | `--regenerate-all` | - -Filter which resources to regenerate using a glob pattern matched against the resource Kind name. Uses `fnmatch` semantics. - -```bash - # Regenerate only Pod-related resources - class-generator --regenerate-all --filter "Pod*" - - # Regenerate resources ending in "Service" - class-generator --regenerate-all --filter "*Service" - - # Regenerate a specific resource - class-generator --regenerate-all --filter "VirtualMachine" -``` - ---- - -### Verification - -Verify that generated files still match what class-generator would produce. No file is modified. - -| Option | Description | -|--------|-------------| -| `--verify` | Verify the kinds passed via `--kind` | -| `--verify-all` | Verify every generated resource; combine with `--filter` to narrow the scope | -| `--verify-changed` | Verify only resources changed relative to `--base`; untouched files are not checked | -| `--base TEXT` | Ref compared against by `--verify-changed`. Defaults to the current branch's upstream, then to the clone's remote HEAD; the ref is fetched when the clone lacks it and the diff uses the merge base | -| `--classification TEXT` | Comma-separated drift classifications; only files matching them are reported and fail the run | -| `--match` | Require the drift to be *within* `--classification` (a subset) instead of merely containing one of them | -| `--exclude TEXT` | Drop files whose drift contains any of these classifications, leaving only drift that is safe to regenerate | -| `--jobs INTEGER` | Resources verified in parallel (default 10) | - -`--classification`, `--match` and `--exclude` require a verify mode; unknown -classifications are rejected with the list of valid values. - -Every reported file is generator output, so the printed repair command is the plain -per-kind form: - -```bash - class-generator --kind Deployment --overwrite -``` - -Only files whose name class-generator writes are verified (`.py`, or -`_.py` for a kind in several groups). A copied file under another name -is not generator output and is ignored. - -#### Drift classifications - -| Classification | Meaning | -|----------------|---------| -| `blank lines` | Only blank-line differences | -| `docstring add` / `docstring delete` | Docstring text differs | -| `code add` / `code delete` | Code lines differ | -| `signature code add` | Parameters were added (all generated parameters carry defaults, so this is not breaking) | -| `signature code remove` | Parameters were removed | -| `breaking change` | A caller-visible break: a parameter was removed, a parameter became required, a parameter is emitted under a different API key, the base class changed, or `api_group`/`api_version` changed | -| `schema incomplete` | The rendered class has no parameters while the file has some — an unresolvable `$ref`. Reported in addition to `breaking change` | -| `file does not exist` | The expected generated file is missing (or was deleted on this branch) | -| `formatting failed` | The rendered output could not be formatted | -| `not in schema` | The kind is absent from the resources mapping | - -`code delete` on its own is not breaking: it also covers guard lines removed when a -field becomes optional, rewritten statements and reflowed code. - -#### Exit status - -`0` when everything matches, `1` when any file drifted or could not be verified. - -#### CI - -`tox -e validate-generated` runs `--verify-changed` with no arguments, so tox stays -CI-independent: - -```bash - tox -e validate-generated -``` - -### Logging - -#### `-v`, `--verbose` - -| Property | Value | -|----------|-------| -| **Type** | Flag | -| **Default** | `False` | - -Enable verbose output with debug-level logs. Sets `DEBUG` level on the following loggers: - -- `class_generator.core.schema` -- `class_generator.core.generator` -- `class_generator.core.coverage` -- `class_generator.core.discovery` -- `class_generator.cli` -- `class_generator.utils` -- `ocp_resources` - -```bash - class-generator -k Pod -v -``` - ---- - -## Constraint Rules - -The CLI enforces the following option constraints: - -| Constraint | Rule | -|------------|------| -| `--update-schema` ↔ `--update-schema-for` | Mutually exclusive | -| `--update-schema` (alone) | Cannot combine with `-k`, `--discover-missing`, `--coverage-report`, `--dry-run`, `--overwrite`, `-o`, `--add-tests`, `--regenerate-all` | -| `--update-schema-for` | Cannot combine with `-k`, `--discover-missing`, `--coverage-report`, `--generate-missing`, `--regenerate-all` | -| `--backup` | Requires `--regenerate-all` or `--overwrite` | -| `--filter` | Requires `--regenerate-all`, `--verify-all` or `--verify-changed` | -| `--verify` / `--verify-all` / `--verify-changed` | Mutually exclusive, and cannot combine with `--regenerate-all`, `--backup`, `--overwrite`, `--add-tests` or `--dry-run` | -| `--verify` | Requires `--kind` | -| `--classification` / `--exclude` | Require a verify mode | -| `--match` | Requires `--classification` | -| No options | Exits with error — at least one action required | - ---- - -## Execution Order - -When multiple compatible options are specified together, the CLI processes them in this fixed order: - -1. `--update-schema-for` (exits after completion) -2. `--update-schema` (exits unless `--generate-missing` is also set) -3. `--coverage-report` / `--discover-missing` / `--generate-missing` (coverage analysis and reporting) -4. `--generate-missing` (generates classes for missing resources) -5. `--verify` / `--verify-all` / `--verify-changed` (verification, exits after completion) -6. `--regenerate-all` (batch regeneration, exits after completion) -7. `-k`/`--kind` (normal kind generation) -8. `--add-tests` (test generation and execution) - ---- - -## Exit Codes - -| Code | Meaning | -|------|---------| -| `0` | Success | -| `1` | Generation failure, schema update failure, resource not found, or any kind in a batch failed | -| `2` | Invalid CLI arguments or constraint violation | - ---- - -## Programmatic API - -The generation logic can be invoked directly from Python. See [Generating New Resource Classes with class-generator](generating-resource-classes.html) for usage examples. - -### `class_generator.core.generator.class_generator()` - -```python - from class_generator.core.generator import class_generator - - generated_files: list[str] = class_generator( - kind="Pod", - overwrite=False, - dry_run=False, - output_file="", - output_dir="", - add_tests=False, - called_from_cli=True, - update_schema_executed=False, - ) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `kind` | `str` | *(required)* | Kubernetes resource Kind | -| `overwrite` | `bool` | `False` | Overwrite existing files | -| `dry_run` | `bool` | `False` | Preview output without writing files | -| `output_file` | `str` | `""` | Specific output file path | -| `output_dir` | `str` | `""` | Output directory (defaults to `ocp_resources`) | -| `add_tests` | `bool` | `False` | Generate test files | -| `called_from_cli` | `bool` | `True` | Enables interactive prompts when `True` | -| `update_schema_executed` | `bool` | `False` | Whether schema update was already performed | - -**Returns:** `list[str]` — List of generated file paths. Empty list if the kind is not found in the schema mapping (when `called_from_cli=False`). - -**Raises:** -- `RuntimeError` — Kind not found after schema update, or user declined schema update -- `ValueError` — Generated filename contains invalid patterns (single-letter segments) - ---- - -### `class_generator.core.generator.generate_resource_file_from_dict()` - -```python - from class_generator.core.generator import generate_resource_file_from_dict - - orig_filename, generated_filename = generate_resource_file_from_dict( - resource_dict={"kind": "Pod", ...}, - overwrite=False, - dry_run=False, - output_file="", - add_tests=False, - output_file_suffix="", - output_dir="", - ) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `resource_dict` | `dict[str, Any]` | *(required)* | Dictionary containing parsed resource information | -| `overwrite` | `bool` | `False` | Overwrite existing files | -| `dry_run` | `bool` | `False` | Preview without writing | -| `output_file` | `str` | `""` | Specific output file path | -| `add_tests` | `bool` | `False` | Generate test files under `class_generator/tests/manifests/` | -| `output_file_suffix` | `str` | `""` | Suffix appended to filename (for API group disambiguation) | -| `output_dir` | `str` | `""` | Output directory (defaults to `ocp_resources`) | - -**Returns:** `tuple[str, str]` — `(original_filename, generated_filename)`. These differ when a `_TEMP.py` file is created. - ---- - -### `class_generator.core.schema.update_kind_schema()` - -```python - from class_generator.core.schema import update_kind_schema - - update_kind_schema(client=None) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `client` | `str \| None` | `None` | Path to `oc`/`kubectl` binary. Auto-detected if `None`. | - -**Raises:** -- `ClusterVersionError` — Cannot determine cluster version -- `RuntimeError` — Failed to fetch OpenAPI v3 index -- `OSError` — Failed to write schema files - ---- - -### `class_generator.core.schema.update_single_resource_schema()` - -```python - from class_generator.core.schema import update_single_resource_schema - - update_single_resource_schema(kind="LlamaStackDistribution", client=None) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `kind` | `str` | *(required)* | Resource Kind name (case-sensitive) | -| `client` | `str \| None` | `None` | Path to `oc`/`kubectl` binary. Auto-detected if `None`. | - -**Raises:** -- `ResourceNotFoundError` — Kind not found on the cluster -- `RuntimeError` — Schema extraction or save failed - ---- - -### `class_generator.core.coverage.analyze_coverage()` - -```python - from class_generator.core.coverage import analyze_coverage - - result: dict[str, Any] = analyze_coverage(resources_dir="ocp_resources") -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `resources_dir` | `str` | `"ocp_resources"` | Directory to scan for wrapper classes | - -**Returns:** `dict[str, Any]` with keys: - -| Key | Type | Description | -|-----|------|-------------| -| `generated_resources` | `list[str]` | Sorted list of auto-generated resource class names | -| `manual_resources` | `list[str]` | Sorted list of manually written resource class names | -| `missing_resources` | `list[dict]` | List of `{"kind": "..."}` for resources in schema but not generated | -| `coverage_stats` | `dict` | Statistics including `total_in_mapping`, `total_generated`, `total_manual`, `coverage_percentage`, `missing_count` | - ---- - -### `class_generator.core.discovery.discover_generated_resources()` - -```python - from class_generator.core.discovery import discover_generated_resources - - resources: list[dict[str, Any]] = discover_generated_resources() -``` - -**Returns:** `list[dict[str, Any]]` — Each dict contains: - -| Key | Type | Description | -|-----|------|-------------| -| `path` | `str` | Full path to the resource file | -| `kind` | `str` | Resource class name | -| `filename` | `str` | File name without extension | -| `has_user_code` | `bool` | Whether file contains user modifications below `# End of generated code` | - ---- - -### `class_generator.core.discovery.discover_cluster_resources()` - -```python - from class_generator.core.discovery import discover_cluster_resources - - resources: dict[str, list[dict[str, Any]]] = discover_cluster_resources( - client=None, - api_group_filter=None, - ) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `client` | `DynamicClient \| None` | `None` | Kubernetes dynamic client. Creates one if `None`. | -| `api_group_filter` | `str \| None` | `None` | Filter by API group name | - -**Returns:** `dict[str, list[dict[str, Any]]]` — Mapping of API version to list of resource dicts (`name`, `kind`, `namespaced`). - -**Raises:** `ValueError` — If client is not a `DynamicClient` instance. - ---- - -## Exceptions - -### `class_generator.exceptions.ResourceNotFoundError` - -```python - from class_generator.exceptions import ResourceNotFoundError -``` - -Raised when a resource Kind is not found in the schema mapping or on the cluster. - -| Attribute | Type | Description | -|-----------|------|-------------| -| `kind` | `str` | The Kind that was not found | - -### `class_generator.core.schema.ClusterVersionError` - -```python - from class_generator.core.schema import ClusterVersionError -``` - -Raised when the cluster version cannot be determined (client binary missing, cluster unreachable, or authentication failure). - ---- - -## Schema Files - -The CLI manages two schema files under `class_generator/schema/`: - -| File | Purpose | -|------|---------| -| `__resources-mappings.json.gz` | Compressed JSON mapping of lowercase Kind → list of schemas with GVK metadata and namespaced status | -| `_definitions.json` | JSON Schema definitions for `$ref` resolution during validation | -| `__cluster_version__.txt` | Last cluster version used for schema generation | - -See [Schema Validation and Code Generation Architecture](schema-validation-internals.html) for details on how these files are structured and consumed. - ---- - -## Common Workflows - -### Generate a new resource class - -```bash - class-generator -k MyCustomResource -``` - -### Update schema and regenerate all classes - -```bash - class-generator --update-schema - class-generator --regenerate-all --backup -``` - -### Add a new CRD to an older cluster - -```bash - class-generator --update-schema-for MyNewCRD - class-generator -k MyNewCRD -``` - -### CI/CD coverage check - -```bash - class-generator --coverage-report --json > coverage.json -``` - -### Preview changes before writing - -```bash - class-generator -k Pod --dry-run - class-generator --regenerate-all --dry-run -``` - ---- - -## Related Pages - -- [Generating New Resource Classes with class-generator](generating-resource-classes.html) — Step-by-step guide for scaffolding resource classes -- [Resource and NamespacedResource API](resource-api.html) — API reference for the base classes that generated code extends -- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) — How generated subclasses fit into the class hierarchy -- [Schema Validation and Code Generation Architecture](schema-validation-internals.html) — Internals of schema fetching, caching, and code generation -- [Environment Variables and Configuration](environment-variables.html) — Environment variables that affect runtime behavior - -## Related Pages - -- [Generating New Resource Classes with class-generator](generating-resource-classes.html) -- [Schema Validation and Code Generation Architecture](schema-validation-internals.html) -- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) -- [Resource and NamespacedResource API](resource-api.html) -- [Environment Variables and Configuration](environment-variables.html) - - ---- - -Source: common-patterns.md - -# Common Resource Patterns - -Copy-paste recipes for the most common Kubernetes and OpenShift resource operations using openshift-python-wrapper. - -> **Note:** All recipes assume you already have a connected client. See [Connecting to Clusters](connecting-to-clusters.html) for setup instructions. - -```python - # Preamble used by all recipes below - from ocp_resources.resource import get_client - - client = get_client() -``` - ---- - -## Create and Run a Pod - -Create a pod with a single container and wait for it to start. - -```python - from ocp_resources.pod import Pod - - pod = Pod( - client=client, - name="my-nginx", - namespace="default", - containers=[ - { - "name": "nginx", - "image": "nginx:1.25", - "ports": [{"containerPort": 80}], - } - ], - restart_policy="Never", - ) - pod.deploy() - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120) -``` - -The `deploy()` method creates the pod on the cluster. `wait_for_status` polls until the pod reaches `Running`. Use `restart_policy="Always"` for long-running pods. - ---- - -## Create a Pod as a Context Manager - -Automatically clean up a pod when the block exits — ideal for tests. - -```python - from ocp_resources.pod import Pod - - with Pod( - client=client, - name="test-curl", - namespace="default", - containers=[ - { - "name": "curl", - "image": "curlimages/curl:8.5.0", - "command": ["sleep", "3600"], - } - ], - ) as pod: - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=60) - output = pod.execute(command=["curl", "-s", "http://httpbin.org/get"]) - print(output) - # Pod is automatically deleted here -``` - -The context manager calls `deploy()` on enter and `clean_up()` on exit. Set `teardown=False` to skip automatic deletion. - -> **Tip:** See [Creating and Managing Resources](creating-and-managing-resources.html) for all lifecycle management patterns. - ---- - -## Execute a Command Inside a Pod - -Run a shell command inside an already-running pod and capture the output. - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True) - output = pod.execute(command=["cat", "/etc/nginx/nginx.conf"], timeout=30) - print(output) -``` - -`execute()` streams output via the Kubernetes exec API. Use `container="sidecar"` to target a specific container in multi-container pods. Set `ignore_rc=True` to suppress errors from non-zero exit codes. - ---- - -## Get Pod Logs - -Retrieve logs from a running or completed pod. - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True) - - # Full logs - print(pod.log()) - - # Last 50 lines - print(pod.log(tail_lines=50)) - - # Logs from a specific container - print(pod.log(container="sidecar")) -``` - -The `log()` method passes kwargs through to the Kubernetes `read_namespaced_pod_log` API, so all standard parameters like `tail_lines`, `since_seconds`, and `container` are supported. - ---- - -## Create a Deployment and Wait for Replicas - -Deploy an application with multiple replicas and wait until they are all ready. - -```python - from ocp_resources.deployment import Deployment - - dep = Deployment( - client=client, - name="web-app", - namespace="default", - replicas=3, - selector={"matchLabels": {"app": "web-app"}}, - template={ - "metadata": {"labels": {"app": "web-app"}}, - "spec": { - "containers": [ - { - "name": "app", - "image": "nginx:1.25", - "ports": [{"containerPort": 80}], - } - ] - }, - }, - ) - dep.deploy() - dep.wait_for_replicas(deployed=True, timeout=300) -``` - -`wait_for_replicas` polls until `availableReplicas == readyReplicas == updatedReplicas == spec.replicas`. Pass `deployed=False` to wait for all replicas to be scaled down. - ---- - -## Scale a Deployment - -Change the replica count of an existing deployment. - -```python - from ocp_resources.deployment import Deployment - - dep = Deployment(client=client, name="web-app", namespace="default", ensure_exists=True) - - # Scale up - dep.scale_replicas(replica_count=5) - dep.wait_for_replicas(deployed=True, timeout=300) - - # Scale down - dep.scale_replicas(replica_count=1) - dep.wait_for_replicas(deployed=True, timeout=300) -``` - -`scale_replicas` patches the deployment's `spec.replicas` field. Always follow with `wait_for_replicas` to confirm the rollout completes. - ---- - -## Create a Service - -Expose a deployment via a ClusterIP service. - -```python - from ocp_resources.service import Service - - svc = Service( - client=client, - name="web-app-svc", - namespace="default", - selector={"app": "web-app"}, - ports=[ - { - "protocol": "TCP", - "port": 80, - "targetPort": 80, - } - ], - type="ClusterIP", - ) - svc.deploy() -``` - -Change `type` to `"NodePort"` or `"LoadBalancer"` as needed. The `selector` must match labels on your target pods. - ---- - -## Create an OpenShift Route - -Expose a service externally via an OpenShift Route. - -```python - from ocp_resources.route import Route - - route = Route( - client=client, - name="web-app-route", - namespace="default", - service="web-app-svc", - ) - route.deploy() - - # Get the assigned hostname - print(route.host) -``` - -For TLS re-encrypt routes, pass `destination_ca_cert=""`. Access the exposed service name with `route.exposed_service` and the TLS termination type with `route.termination`. - ---- - -## Create a Namespace - -Create a namespace (or use as a context manager for automatic cleanup). - -```python - from ocp_resources.namespace import Namespace - - # Simple creation - ns = Namespace(client=client, name="test-ns") - ns.deploy() - - # As a context manager (deleted on exit) - with Namespace(client=client, name="ephemeral-ns") as ns: - # ... do work in the namespace ... - pass -``` - -`Namespace` extends `Resource` (cluster-scoped), so no `namespace` parameter is needed. - ---- - -## Create a ConfigMap - -Store configuration data for pods to consume. - -```python - from ocp_resources.config_map import ConfigMap - - cm = ConfigMap( - client=client, - name="app-config", - namespace="default", - data={ - "DATABASE_URL": "postgres://db:5432/myapp", - "LOG_LEVEL": "info", - }, - ) - cm.deploy() -``` - -Use `binary_data` for non-UTF-8 content. Set `immutable=True` to prevent changes after creation. - ---- - -## Create a Secret - -Store sensitive data such as credentials. - -```python - from ocp_resources.secret import Secret - - secret = Secret( - client=client, - name="db-credentials", - namespace="default", - string_data={ - "username": "admin", - "password": "s3cur3-pa$$word", - }, - type="Opaque", - ) - secret.deploy() -``` - -Use `data_dict` instead of `string_data` if your values are already base64-encoded. Secret data is automatically hashed in log output for security. - ---- - -## Create a Job - -Run a one-off task to completion. - -```python - from ocp_resources.job import Job - - job = Job( - client=client, - name="db-migration", - namespace="default", - backoff_limit=3, - restart_policy="Never", - containers=[ - { - "name": "migrate", - "image": "my-app:latest", - "command": ["python", "manage.py", "migrate"], - } - ], - ) - job.deploy() - job.wait_for_condition( - condition=Job.Condition.COMPLETE, - status=Job.Condition.Status.TRUE, - timeout=300, - ) -``` - -Set `background_propagation_policy="Background"` to delete leftover pods when the job is cleaned up. The `backoff_limit` controls how many times the job retries on failure. - ---- - -## Create a PersistentVolumeClaim - -Request persistent storage for your workloads. - -```python - from ocp_resources.persistent_volume_claim import PersistentVolumeClaim - - pvc = PersistentVolumeClaim( - client=client, - name="app-data", - namespace="default", - accessmodes=PersistentVolumeClaim.AccessMode.RWO, - size="10Gi", - storage_class="gp3-csi", - volume_mode=PersistentVolumeClaim.VolumeMode.FILE, - ) - pvc.deploy() - pvc.wait_for_status(status=PersistentVolumeClaim.Status.BOUND, timeout=120) -``` - -Use the `AccessMode` and `VolumeMode` constants instead of raw strings. The `storage_class` must match an available StorageClass on your cluster. - ---- - -## Create a DataVolume (KubeVirt / CDI) - -Import a VM disk image into a PVC using the Containerized Data Importer. - -```python - from ocp_resources.datavolume import DataVolume - - dv = DataVolume( - client=client, - name="fedora-disk", - namespace="default", - source_dict={ - "http": { - "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/40/Cloud/x86_64/images/Fedora-Cloud-Base-40-1.14.x86_64.qcow2" - } - }, - size="30Gi", - storage_class="ocs-storagecluster-ceph-rbd-virtualization", - access_modes=DataVolume.AccessMode.RWX, - volume_mode=DataVolume.VolumeMode.BLOCK, - api_name="storage", - ) - dv.deploy() - dv.wait_for_dv_success(timeout=600) -``` - -Always pass `api_name="storage"` explicitly — the default will change in a future release. Use `source_dict={"blank": {}}` for empty disks, or `source_dict={"pvc": {"name": "source-pvc", "namespace": "default"}}` for cloning. - -- **Clone a DataVolume:** - ```python - dv_clone = DataVolume( - client=client, - name="fedora-disk-clone", - namespace="default", - source_dict={"pvc": {"name": "fedora-disk", "namespace": "default"}}, - size="30Gi", - storage_class="ocs-storagecluster-ceph-rbd-virtualization", - access_modes=DataVolume.AccessMode.RWX, - volume_mode=DataVolume.VolumeMode.BLOCK, - api_name="storage", - ) - ``` - -> **Tip:** See [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) for full VM lifecycle recipes. - ---- - -## Create a NetworkPolicy - -Restrict traffic to pods matching a label selector. - -```python - from ocp_resources.network_policy import NetworkPolicy - - netpol = NetworkPolicy( - client=client, - name="allow-http-only", - namespace="default", - pod_selector={"matchLabels": {"app": "web-app"}}, - policy_types=["Ingress"], - ingress=[ - { - "ports": [{"protocol": "TCP", "port": 80}], - "from": [ - {"namespaceSelector": {"matchLabels": {"env": "production"}}}, - ], - } - ], - ) - netpol.deploy() -``` - -This allows TCP port 80 ingress only from namespaces labeled `env=production`. Omit `ingress` and set `policy_types=["Ingress"]` to deny all inbound traffic. - ---- - -## Create a NetworkAttachmentDefinition - -Attach pods to a secondary network using Multus. - -```python - from ocp_resources.network_attachment_definition import LinuxBridgeNetworkAttachmentDefinition - - nad = LinuxBridgeNetworkAttachmentDefinition( - client=client, - name="br-100", - namespace="default", - bridge_name="br-100", - cni_type="cnv-bridge", - vlan=100, - mtu=1500, - ) - nad.deploy() -``` - -Variant classes are available for different bridge types: -- `LinuxBridgeNetworkAttachmentDefinition` — Linux bridge (cnv-bridge) -- `OvsBridgeNetworkAttachmentDefinition` — OVS bridge -- `OVNOverlayNetworkAttachmentDefinition` — OVN overlay (layer 2/3) - ---- - -## Create an RBAC Role and RoleBinding - -Grant specific permissions to a service account within a namespace. - -```python - from ocp_resources.role import Role - from ocp_resources.role_binding import RoleBinding - from ocp_resources.service_account import ServiceAccount - - sa = ServiceAccount( - client=client, - name="app-sa", - namespace="default", - ) - sa.deploy() - - role = Role( - client=client, - name="pod-reader", - namespace="default", - rules=[ - { - "apiGroups": [""], - "resources": ["pods", "pods/log"], - "verbs": ["get", "list", "watch"], - } - ], - ) - role.deploy() - - binding = RoleBinding( - client=client, - name="app-sa-pod-reader", - namespace="default", - subjects_kind="ServiceAccount", - subjects_name="app-sa", - subjects_namespace="default", - role_ref_kind="Role", - role_ref_name="pod-reader", - ) - binding.deploy() -``` - -This grants the `app-sa` service account read-only access to pods in the `default` namespace. - ---- - -## Create a ClusterRole and ClusterRoleBinding - -Grant cluster-wide permissions to a service account. - -```python - from ocp_resources.cluster_role import ClusterRole - from ocp_resources.cluster_role_binding import ClusterRoleBinding - - cr = ClusterRole( - client=client, - name="node-viewer", - rules=[ - { - "apiGroups": [""], - "resources": ["nodes"], - "verbs": ["get", "list", "watch"], - } - ], - ) - cr.deploy() - - crb = ClusterRoleBinding( - client=client, - name="app-sa-node-viewer", - cluster_role="node-viewer", - subjects=[ - { - "kind": "ServiceAccount", - "name": "app-sa", - "namespace": "default", - } - ], - ) - crb.deploy() -``` - -`ClusterRole` and `ClusterRoleBinding` are cluster-scoped (`Resource` subclasses), so no `namespace` parameter is needed on the role or binding itself. - ---- - -## List and Filter Resources - -Query existing resources using label and field selectors. - -```python - from ocp_resources.pod import Pod - from ocp_resources.namespace import Namespace - - # List all pods in a namespace - for pod in Pod.get(client=client, namespace="default"): - print(f"{pod.name} — {pod.status}") - - # Filter by label - for pod in Pod.get(client=client, namespace="default", label_selector="app=web-app"): - print(pod.name) - - # Filter by field - for pod in Pod.get(client=client, namespace="default", field_selector="status.phase=Running"): - print(pod.name) - - # List cluster-scoped resources (no namespace) - for ns in Namespace.get(client=client, label_selector="env=staging"): - print(ns.name) -``` - -The `get()` class method returns a generator of resource objects. Pass `raw=True` to get raw `ResourceInstance` objects instead. - -> **Tip:** See [Querying and Watching Resources](querying-resources.html) for advanced querying patterns. - ---- - -## Create a Resource from a YAML File - -Load a resource definition from an existing YAML file. - -```python - from ocp_resources.pod import Pod - - pod = Pod( - client=client, - yaml_file="manifests/my-pod.yaml", - ) - pod.deploy() -``` - -When using `yaml_file`, the `name` and `namespace` are read from the file automatically. You cannot combine `yaml_file` with `kind_dict`. - ---- - -## Create a Resource from a Dictionary - -Pass a raw Kubernetes resource dict directly. - -```python - from ocp_resources.deployment import Deployment - - dep = Deployment( - client=client, - kind_dict={ - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "name": "from-dict-app", - "namespace": "default", - }, - "spec": { - "replicas": 2, - "selector": {"matchLabels": {"app": "from-dict"}}, - "template": { - "metadata": {"labels": {"app": "from-dict"}}, - "spec": { - "containers": [ - { - "name": "app", - "image": "nginx:1.25", - } - ], - }, - }, - }, - }, - ) - dep.deploy() -``` - -Using `kind_dict` bypasses all constructor logic in `to_dict()` — the dictionary is sent as-is to the API. This is useful when migrating existing manifests. - ---- - -## Handle Resources with Duplicate API Groups - -Some Kubernetes resource kinds (e.g., `DNS`, `Ingress`, `Gateway`) exist in multiple API groups. The wrapper provides separate modules for each variant. - -```python - # DNS from config.openshift.io (cluster DNS config) - from ocp_resources.dns_config_openshift_io import DNS as DNSConfig - - dns_config = DNSConfig(client=client, name="cluster", ensure_exists=True) - print(dns_config.instance.spec.baseDomain) - - # DNS from operator.openshift.io (CoreDNS operator) - from ocp_resources.dns_operator_openshift_io import DNS as DNSOperator - - dns_operator = DNSOperator(client=client, name="default", ensure_exists=True) - print(dns_operator.instance.spec.logLevel) -``` - -```python - # Ingress from config.openshift.io (cluster ingress config) - from ocp_resources.ingress_config_openshift_io import Ingress as IngressConfig - - ingress = IngressConfig(client=client, name="cluster", ensure_exists=True) - print(ingress.instance.spec.domain) - - # Ingress from networking.k8s.io (K8s Ingress rules) - from ocp_resources.ingress_networking_k8s_io import Ingress as K8sIngress - - k8s_ingress = K8sIngress( - client=client, - name="my-app-ingress", - rules=[ - { - "host": "app.example.com", - "http": { - "paths": [ - { - "path": "/", - "pathType": "Prefix", - "backend": {"service": {"name": "web-app-svc", "port": {"number": 80}}}, - } - ], - }, - } - ], - ) -``` - -The naming convention for duplicate-kind modules is `_.py`. Use Python import aliases (`as`) to distinguish them in your code. - -**Common duplicate kinds and their modules:** - -| Kind | Module | API Group | -|------|--------|-----------| -| `DNS` | `dns_config_openshift_io` | `config.openshift.io` | -| `DNS` | `dns_operator_openshift_io` | `operator.openshift.io` | -| `Ingress` | `ingress_config_openshift_io` | `config.openshift.io` | -| `Ingress` | `ingress_networking_k8s_io` | `networking.k8s.io` | -| `Gateway` | `gateway` | `networking.istio.io` | -| `Gateway` | `gateway_gateway_networking_k8s_io` | `gateway.networking.k8s.io` | -| `Gateway` | `gateway_networking_istio_io` | `networking.istio.io` | - -> **Warning:** Importing the wrong module will target the wrong API group, causing `NotFoundError` or unexpected behavior. Always verify the `api_group` attribute on your resource class. - ---- - -## Manage Multiple Similar Resources with ResourceList - -Create N copies of a resource with auto-numbered names. - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import ResourceList - - with ResourceList( - resource_class=Namespace, - num_resources=3, - client=client, - name="perf-test-ns", - ) as namespaces: - # Creates: perf-test-ns-1, perf-test-ns-2, perf-test-ns-3 - for ns in namespaces: - print(ns.name) - # All 3 namespaces are deleted on exit -``` - -> **Tip:** See [Managing Bulk Resources with ResourceList](managing-resource-lists.html) for `NamespacedResourceList` and other bulk patterns. - ---- - -## Deploy One Resource Per Namespace with NamespacedResourceList - -Create an identical namespaced resource in each of several namespaces. - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.config_map import ConfigMap - from ocp_resources.resource import ResourceList, NamespacedResourceList - - with ResourceList( - resource_class=Namespace, - num_resources=3, - client=client, - name="team-ns", - ) as namespaces: - with NamespacedResourceList( - resource_class=ConfigMap, - namespaces=namespaces, - client=client, - name="shared-config", - data={"REGION": "us-east-1"}, - ) as configmaps: - # One ConfigMap "shared-config" in each of team-ns-1, team-ns-2, team-ns-3 - for cm in configmaps: - print(f"{cm.namespace}/{cm.name}") -``` - -`NamespacedResourceList` requires a `ResourceList` of `Namespace` objects. All resources are cleaned up in reverse order on exit. - ---- - -## Temporarily Edit a Resource with ResourceEditor - -Apply temporary patches during a test and automatically restore original values. - -```python - from ocp_resources.resource import ResourceEditor - from ocp_resources.config_map import ConfigMap - - cm = ConfigMap(client=client, name="app-config", namespace="default", ensure_exists=True) - - with ResourceEditor( - patches={cm: {"data": {"LOG_LEVEL": "debug"}}}, - action="update", - ): - # LOG_LEVEL is now "debug" - print(cm.instance.data.LOG_LEVEL) - # Original LOG_LEVEL is restored here -``` - -`ResourceEditor` backs up original values on enter and restores them on exit. Use `action="replace"` when you need to remove fields entirely rather than patch them. - -> **Tip:** See [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html) for complete details. - ---- - -## Validate a Resource Before Creating It - -Catch schema errors before submitting to the API server. - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ValidationError - - pod = Pod( - client=client, - name="validated-pod", - namespace="default", - containers=[ - { - "name": "app", - "image": "nginx:1.25", - } - ], - schema_validation_enabled=True, # Auto-validate on create() - ) - - # Manual validation - try: - pod.validate() - print("Resource is valid") - except ValidationError as e: - print(f"Validation failed: {e}") - - # Or validate a raw dict without instantiation - try: - Pod.validate_dict({ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "test"}, - "spec": {"containers": [{"name": "app", "image": "nginx"}]}, - }) - except ValidationError as e: - print(f"Dict validation failed: {e}") -``` - -When `schema_validation_enabled=True`, `create()` and `update_replace()` automatically validate before sending to the API. Validation requires OpenAPI schemas to be available. - -> **Tip:** See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for schema setup and troubleshooting. - ---- - -## Check If a Resource Exists - -Verify a resource exists before operating on it. - -```python - from ocp_resources.deployment import Deployment - - dep = Deployment(client=client, name="web-app", namespace="default") - - if dep.exists: - print(f"Deployment exists, status: {dep.instance.status.availableReplicas} replicas available") - else: - print("Deployment not found") -``` - -`exists` returns the resource instance if found or `None` if not. For fail-fast behavior, use `ensure_exists=True` in the constructor — it raises `ResourceNotFoundError` immediately if the resource is missing. - ---- - -## Watch Resource Events - -Stream events related to a specific resource. - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True) - - # Watch events for this pod (10 second window) - for event in pod.events(timeout=10): - ev = event["object"] - print(f"[{ev.type}] {ev.reason}: {ev.message}") -``` - -Use `field_selector` to narrow results further, for example: `field_selector="type==Warning"`. The `events()` method automatically filters by `involvedObject.name`. - ---- - -## List Recent Events in a Namespace - -Get existing events (not a watch stream) from the last N seconds. - -```python - from ocp_resources.event import Event - - # Warning events from the last 5 minutes - events = Event.list( - client=client, - namespace="default", - field_selector="type==Warning", - since_seconds=300, - ) - for ev in events: - print(f"[{ev.reason}] {ev.message}") -``` - -`Event.list()` returns a sorted list (most recent first), unlike `Event.get()` which is a live watch stream. - ---- - -## Wait for a Resource Condition - -Block until a resource reaches a specific condition. - -```python - from ocp_resources.deployment import Deployment - - dep = Deployment(client=client, name="web-app", namespace="default", ensure_exists=True) - - dep.wait_for_condition( - condition="Available", - status="True", - timeout=300, - ) -``` - -Use `stop_condition` to fail fast if an unrecoverable condition is detected: - -```python - dep.wait_for_condition( - condition="Available", - status="True", - timeout=300, - stop_condition="ReplicaFailure", - stop_status="True", - ) -``` - -> **Tip:** See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for advanced waiting patterns. - ---- - -## Get a Resource's YAML Representation - -Dump the intended resource dict as YAML (useful for debugging). - -```python - from ocp_resources.pod import Pod - - pod = Pod( - client=client, - name="debug-pod", - namespace="default", - containers=[{"name": "app", "image": "busybox", "command": ["sleep", "3600"]}], - ) - print(pod.to_yaml()) -``` - -`to_yaml()` calls `to_dict()` internally and returns the YAML string. This shows what would be sent to the API, not the live state. - ---- - -## Get the Pod's Node and IP - -Access commonly needed runtime properties of a pod. - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-nginx", namespace="default", ensure_exists=True) - - # Node where the pod is running - print(f"Node: {pod.node.name}") - - # Pod IP address - print(f"IP: {pod.ip}") -``` - -The `node` property returns a `Node` resource object. The `ip` property reads from `status.podIP`. - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Querying and Watching Resources](querying-resources.html) -- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) -- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) -- [Working with Kubernetes Events](working-with-events.html) - - ---- - -Source: mcp-server-integration.md - -# Integrating with AI Assistants via MCP Server - -The openshift-python-wrapper ships with a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets AI assistants like Cursor, Claude Desktop, and other MCP-compatible tools directly manage your OpenShift/Kubernetes cluster resources. - -## Configure the MCP Server for Cursor - -Connect your Cursor IDE to your cluster so the AI assistant can list, create, update, and delete resources on your behalf. - -```json - // ~/.cursor/mcp.json - { - "mcpServers": { - "openshift-python-wrapper": { - "command": "openshift-mcp-server" - } - } - } -``` - -After saving, restart Cursor and reference the server with `@openshift-python-wrapper` in your prompts. The server uses your active kubeconfig context automatically. - -> **Note:** The `openshift-mcp-server` command is installed as a script entry point when you install the package. See [Installing and Creating Your First Resource](quickstart.html) for installation instructions. - -## Configure the MCP Server for Claude Desktop - -Set up Claude Desktop to interact with your cluster through natural language. - -```json - // macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - // Windows: %APPDATA%\Claude\claude_desktop_config.json - // Linux: ~/.config/Claude/claude_desktop_config.json - { - "mcpServers": { - "openshift-python-wrapper": { - "command": "openshift-mcp-server" - } - } - } -``` - -Claude Desktop will connect to the server on startup. You can then ask it to list pods, create resources, check logs, and more using natural language. - -## Configure a Custom Kubeconfig Path - -Point the MCP server at a specific kubeconfig when your default location doesn't apply. - -```json - { - "mcpServers": { - "openshift-python-wrapper": { - "command": "openshift-mcp-server", - "env": { - "KUBECONFIG": "/home/user/.kube/my-cluster-config" - } - } - } - } -``` - -The `KUBECONFIG` environment variable is passed to the server process at startup. This is useful when managing multiple clusters or when your kubeconfig is in a non-standard location. - -> **Tip:** See [Connecting to Clusters](connecting-to-clusters.html) for all authentication methods supported by openshift-python-wrapper, including tokens, basic auth, and in-cluster config. - -## Run the MCP Server from Source (Development) - -Run the server directly from a cloned repository for development or testing. - -```bash - git clone https://github.com/RedHatQE/openshift-python-wrapper.git - cd openshift-python-wrapper - uv run mcp_server/server.py -``` - -For MCP client configuration when running from source: - -```json - { - "mcpServers": { - "openshift-python-wrapper": { - "command": "uv", - "args": ["run", "mcp_server/server.py"], - "cwd": "/path/to/openshift-python-wrapper" - } - } - } -``` - -This bypasses the installed entry point and runs the server module directly, which is useful when testing local changes. - -## List Cluster Resources via AI Chat - -Ask your AI assistant to list resources — the MCP server exposes a `list_resources` tool that supports type filtering, namespaces, label selectors, and limits. - -``` - Prompt: "List all pods in the openshift-monitoring namespace with label app=prometheus" -``` - -The AI will call the `list_resources` tool, which maps to: - -```python - list_resources(resource_type="pod", namespace="openshift-monitoring", label_selector="app=prometheus", limit=20) -``` - -Returned data includes name, namespace, UID, creation timestamp, labels, phase, and conditions for each matched resource. - -> **Tip:** Use `get_resource_types` to discover all available resource types. The server dynamically scans every module in `ocp_resources/` at startup, so any resource supported by the library — including OpenShift-specific types like `route`, `virtualmachine`, or `clusterserviceversion` — is available. - -## Get Detailed Resource Information - -Retrieve a single resource with full metadata, status, and optionally the raw YAML or JSON. - -``` - Prompt: "Show me the deployment 'api-server' in namespace 'production' as YAML" -``` - -The AI calls: - -```python - get_resource( - resource_type="deployment", - name="api-server", - namespace="production", - output_format="yaml", # also accepts "json" or "info" - ) -``` - -The `info` format (default) returns structured metadata plus resource-specific details: for pods you get container statuses and node placement; for deployments you get replica counts; for services you get ports and cluster IP. - -## Create Resources Through Natural Language - -Ask the AI to create resources — it can use either YAML content or structured spec parameters. - -``` - Prompt: "Create a ConfigMap called 'app-settings' in the 'staging' namespace with keys - database_host=db.staging.svc and log_level=debug" -``` - -The AI calls: - -```python - create_resource( - resource_type="configmap", - name="app-settings", - namespace="staging", - spec={"data": {"database_host": "db.staging.svc", "log_level": "debug"}}, - labels={"managed-by": "mcp-server"}, - ) -``` - -You can also provide full YAML: - -```python - create_resource( - resource_type="pod", - name="nginx", - yaml_content=""" - apiVersion: v1 - kind: Pod - metadata: - name: nginx - namespace: default - spec: - containers: - - name: nginx - image: nginx:1.27 - ports: - - containerPort: 80 - """, - ) -``` - -> **Warning:** The MCP server creates real resources on your cluster. Make sure your AI assistant is targeting the correct cluster and namespace before confirming creation operations. - -## Apply Multi-Document YAML Manifests - -Deploy a complete application stack from a single YAML block containing multiple resources. - -``` - Prompt: "Apply this manifest to create a namespace, deployment, and service for my app" -``` - -The AI calls: - -```python - apply_yaml( - yaml_content=""" - --- - apiVersion: v1 - kind: Namespace - metadata: - name: my-app - --- - apiVersion: apps/v1 - kind: Deployment - metadata: - name: backend - namespace: my-app - spec: - replicas: 3 - selector: - matchLabels: - app: backend - template: - metadata: - labels: - app: backend - spec: - containers: - - name: api - image: myapp/backend:v1.0 - ports: - - containerPort: 8080 - --- - apiVersion: v1 - kind: Service - metadata: - name: backend - namespace: my-app - spec: - selector: - app: backend - ports: - - port: 80 - targetPort: 8080 - """ - ) -``` - -The response includes a summary with `total_resources`, `successful`, and `failed` counts, plus per-resource results. Each YAML document is parsed and deployed independently. - -## Update Resources with Patches - -Modify existing resources using merge or strategic-merge patches. - -``` - Prompt: "Scale the 'web-frontend' deployment in 'production' to 5 replicas" -``` - -The AI calls: - -```python - update_resource( - resource_type="deployment", - name="web-frontend", - namespace="production", - patch={"spec": {"replicas": 5}}, - patch_type="merge", # or "strategic" - ) -``` - -The `patch_type` parameter controls the content type: `"merge"` uses `application/merge-patch+json`, while `"strategic"` uses `application/strategic-merge-patch+json`. - -## Delete Resources - -Remove resources with optional wait-for-deletion behavior. - -``` - Prompt: "Delete the pod 'debug-pod' in namespace 'default' and wait for it to be gone" -``` - -The AI calls: - -```python - delete_resource(resource_type="pod", name="debug-pod", namespace="default", wait=True, timeout=60) -``` - -If the resource doesn't exist, the server returns a success response with a warning rather than an error, making the operation idempotent. - -## Retrieve Pod Logs - -Fetch logs from running or crashed pods with filtering options. - -``` - Prompt: "Show me the last 200 lines of logs from pod 'api-server-7f8b9c' container 'app' - in namespace 'production'" -``` - -The AI calls: - -```python - get_pod_logs(name="api-server-7f8b9c", namespace="production", container="app", tail_lines=200) -``` - -- Use `since_seconds=3600` to get logs from the last hour -- Use `previous=True` to get logs from a crashed container's previous instance -- Omit `container` for single-container pods - -## Execute Commands in Pods - -Run diagnostic commands inside a running pod container. - -``` - Prompt: "Check the nginx config in pod 'web-server' namespace 'production'" -``` - -The AI calls: - -```python - exec_in_pod(name="web-server", namespace="production", command=["nginx", "-t"], container="nginx") -``` - -The response includes `stdout`, `stderr`, and `returncode`. If the command fails, the exit code and error output are captured rather than raising an exception. - -> **Warning:** Pod exec gives shell-level access to your containers. Ensure your RBAC policies restrict which service accounts and users can perform `exec` operations. - -## Get Resource Events - -Retrieve Kubernetes events related to a specific resource for troubleshooting. - -``` - Prompt: "Show me the events for pod 'crashloop-pod' in 'default' namespace" -``` - -The AI calls: - -```python - get_resource_events(resource_type="pod", name="crashloop-pod", namespace="default", limit=10) -``` - -Events are filtered using field selectors on `involvedObject.name`, `involvedObject.namespace`, and `involvedObject.kind`. Each event includes type (Normal/Warning), reason, message, count, timestamps, and source component. - -## Troubleshoot a Failing Pod (End-to-End Workflow) - -Combine multiple MCP tools in sequence to diagnose pod issues — the AI assistant will chain these calls automatically. - -``` - Prompt: "Pod 'payment-svc-6d4f8' in namespace 'checkout' keeps crashing. Help me figure out why." -``` - -The AI will typically execute this sequence: - -1. **Check status:** `get_resource(resource_type="pod", name="payment-svc-6d4f8", namespace="checkout")` — gets phase, container statuses, restart count -2. **Review events:** `get_resource_events(resource_type="pod", name="payment-svc-6d4f8", namespace="checkout")` — shows scheduling, pulling, crash events -3. **Read current logs:** `get_pod_logs(name="payment-svc-6d4f8", namespace="checkout", tail_lines=100)` — application output before crash -4. **Read previous crash logs:** `get_pod_logs(name="payment-svc-6d4f8", namespace="checkout", previous=True)` — logs from the last terminated container -5. **Inspect config:** `exec_in_pod(name="payment-svc-6d4f8", namespace="checkout", command=["cat", "/etc/app/config.yaml"])` — verify mounted configuration - -The AI then synthesizes all the data into a diagnosis with suggested fixes. - -## Write a Programmatic MCP Client - -Connect to the MCP server programmatically using `FastMCPClient` for automation scripts. - -```python - import asyncio - from fastmcp import FastMCPClient - - - async def main(): - async with FastMCPClient() as client: - await client.connect_stdio(cmd=["openshift-mcp-server"]) - - # Discover available resource types - types = await client.call_tool( - name="get_resource_types", - arguments={"random_string": "x"}, - ) - print(f"Available types: {types['total_count']}") - - # List pods in a namespace - pods = await client.call_tool( - name="list_resources", - arguments={"resource_type": "pod", "namespace": "default", "limit": 5}, - ) - for pod in pods["resources"]: - print(f" {pod['name']} — {pod.get('phase', 'Unknown')}") - - - if __name__ == "__main__": - asyncio.run(main()) -``` - -This uses the same stdio transport that AI assistants use. The `FastMCPClient` is provided by the `fastmcp` library, which is already a dependency of openshift-python-wrapper. - -## Debug MCP Server Issues - -Enable debug logging and inspect the server log file when tools aren't behaving as expected. - -```bash - # Check the MCP server log file - tail -f /tmp/mcp_server_debug.log -``` - -```bash - # Run with debug-level logging from the simple-logger - SIMPLE_LOGGER_LEVEL=DEBUG openshift-mcp-server -``` - -The server writes debug logs to `/tmp/mcp_server_debug.log` by default, including every resource scan at startup, client creation events, and detailed error tracebacks. - -- Verify cluster connectivity independently: `kubectl cluster-info` -- Check your permissions: `kubectl auth can-i --list` -- Confirm the entry point is installed: `which openshift-mcp-server` - -> **Tip:** See [Environment Variables and Configuration](environment-variables.html) for other environment variables that affect library behavior. - -## Available MCP Tools Reference - -The MCP server exposes ten tools. All resource type names are case-insensitive (e.g., `"Pod"`, `"pod"`, and `"POD"` all work). - -| Tool | Purpose | Required Parameters | -|------|---------|-------------------| -| `list_resources` | List resources by type with optional filters | `resource_type` | -| `get_resource` | Get a single resource by name | `resource_type`, `name` | -| `create_resource` | Create a resource from spec or YAML | `resource_type`, `name`, plus `spec` or `yaml_content` | -| `update_resource` | Patch an existing resource | `resource_type`, `name`, `patch` | -| `delete_resource` | Delete a resource | `resource_type`, `name` | -| `get_pod_logs` | Retrieve pod container logs | `name`, `namespace` | -| `exec_in_pod` | Execute a command in a pod | `name`, `namespace`, `command` | -| `get_resource_events` | Get events for a resource | `resource_type`, `name` | -| `apply_yaml` | Apply one or more YAML documents | `yaml_content` | -| `get_resource_types` | List all discovered resource types | `random_string` (any value) | - -> **Note:** Namespaced resources (pods, deployments, configmaps, etc.) require a `namespace` parameter. Cluster-scoped resources (nodes, namespaces, cluster roles) do not. The server validates this automatically and returns a clear error if a namespace is missing. - -## Related Pages - -- [Connecting to Clusters](connecting-to-clusters.html) -- [Installing and Creating Your First Resource](quickstart.html) -- [Environment Variables and Configuration](environment-variables.html) -- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) -- [Querying and Watching Resources](querying-resources.html) - - ---- - -Source: resource-api.md - -# Resource and NamespacedResource API - -Complete API reference for the `Resource` and `NamespacedResource` base classes, the `get_client` factory function, and supporting classes (`ResourceEditor`, `ResourceList`, `NamespacedResourceList`, `KubeAPIVersion`). - -**Import path:** - -```python - from ocp_resources.resource import ( - Resource, - NamespacedResource, - ResourceEditor, - ResourceList, - NamespacedResourceList, - get_client, - ) -``` - ---- - -## `get_client` - -Factory function to obtain a Kubernetes `DynamicClient`. - -```python - from ocp_resources.resource import get_client - - client = get_client(config_file="~/.kube/config") -``` - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `config_file` | `str \| None` | `None` | Path to a kubeconfig file. Falls back to `KUBECONFIG` env var or `~/.kube/config`. | -| `config_dict` | `dict[str, Any] \| None` | `None` | Dict with kubeconfig configuration. Mutually exclusive with `config_file`. | -| `context` | `str \| None` | `None` | Name of the kubeconfig context to use. | -| `client_configuration` | `kubernetes.client.Configuration \| None` | `None` | Pre-built Kubernetes client configuration object. | -| `persist_config` | `bool` | `True` | Whether to persist config file changes. | -| `temp_file_path` | `str \| None` | `None` | Path to a temporary kubeconfig file. | -| `try_refresh_token` | `bool` | `True` | Try to refresh the authentication token. | -| `username` | `str \| None` | `None` | Username for basic auth. Requires `password` and `host`. | -| `password` | `str \| None` | `None` | Password for basic auth. Requires `username` and `host`. | -| `host` | `str \| None` | `None` | Cluster host URL. | -| `verify_ssl` | `bool \| None` | `None` | Whether to verify SSL certificates. | -| `token` | `str \| None` | `None` | Bearer token for authentication. Requires `host`. | -| `fake` | `bool` | `False` | Return a `FakeDynamicClient` for testing without a cluster. | -| `generate_kubeconfig` | `bool` | `False` | Save the resolved kubeconfig to a temp file and attach the path to the client. | - -**Returns:** `DynamicClient | FakeDynamicClient` - -```python - # Default kubeconfig - client = get_client() - - # With explicit config file and context - client = get_client(config_file="/path/to/kubeconfig", context="my-cluster") - - # Token-based auth - client = get_client(host="https://api.cluster.example.com:6443", token="sha256~abc123") - - # Basic auth - client = get_client(host="https://api.cluster.example.com:6443", username="admin", password="secret") - - # Fake client for unit testing - client = get_client(fake=True) -``` - -> **Note:** If neither `config_file` nor `config_dict` is provided, the client falls back to the `KUBECONFIG` environment variable, then `~/.kube/config`, and finally in-cluster configuration. - -See [Connecting to Clusters](connecting-to-clusters.html) for connection patterns. See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for `fake=True` usage. - ---- - -## `Resource` - -```python - from ocp_resources.resource import Resource -``` - -Base class for **cluster-scoped** Kubernetes/OpenShift resources (e.g., `Namespace`, `Node`, `ClusterRole`). Inherits from `ResourceConstants`. All concrete resource classes inherit from either `Resource` or `NamespacedResource`. - -See [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) for the inheritance model. - -### Class Attributes - -| Attribute | Type | Default | Description | -|---|---|---|---| -| `api_group` | `str` | `""` | API group for the resource (e.g., `"apps"`, `"batch"`). | -| `api_version` | `str` | `""` | Full API version string (e.g., `"v1"`, `"apps/v1"`). Resolved automatically if `api_group` is set. | -| `singular_name` | `str` | `""` | Singular resource name for API calls. Used to disambiguate when multiple resources match the same kind. | -| `timeout_seconds` | `int` | `60` | Default timeout for API list/watch operations. | - -### Constructor - -```python - Resource( - client=client, - name="my-resource", - ) -``` - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `client` | `DynamicClient \| None` | `None` | Kubernetes dynamic client. Will be mandatory in the next major release. | -| `name` | `str \| None` | `None` | Resource name. Required unless `yaml_file` or `kind_dict` is provided. | -| `teardown` | `bool` | `True` | Whether this resource should be deleted when used as a context manager. | -| `yaml_file` | `str \| None` | `None` | Path to a YAML file defining the resource. Mutually exclusive with `kind_dict`. | -| `delete_timeout` | `int` | `240` | Timeout in seconds for delete operations. | -| `dry_run` | `bool` | `False` | If `True`, create operations use dry-run mode. | -| `node_selector` | `dict[str, Any] \| None` | `None` | Node selector for scheduling. | -| `node_selector_labels` | `dict[str, str] \| None` | `None` | Node selector labels for scheduling. | -| `config_file` | `str \| None` | `None` | Path to kubeconfig. Deprecated; pass `client` instead. | -| `config_dict` | `dict[str, Any] \| None` | `None` | Kubeconfig dict. | -| `context` | `str \| None` | `None` | Kubeconfig context name. Deprecated; pass `client` instead. | -| `label` | `dict[str, str] \| None` | `None` | Labels to set on the resource. | -| `annotations` | `dict[str, str] \| None` | `None` | Annotations to set on the resource. | -| `api_group` | `str` | `""` | Override the class-level `api_group`. | -| `hash_log_data` | `bool` | `True` | Hash sensitive fields (defined by `keys_to_hash`) in log output. | -| `ensure_exists` | `bool` | `False` | Check that the resource already exists on the cluster at init time. Raises `ResourceNotFoundError` if not. | -| `kind_dict` | `dict[Any, Any] \| None` | `None` | Complete resource dict. Mutually exclusive with `yaml_file`. Bypasses `to_dict()` logic. | -| `wait_for_resource` | `bool` | `False` | When used as a context manager, wait for the resource to exist after creation. | -| `schema_validation_enabled` | `bool` | `False` | Enable automatic OpenAPI schema validation on `create()` and `update_replace()`. | - -**Raises:** - -| Exception | Condition | -|---|---| -| `ValueError` | Both `yaml_file` and `kind_dict` are provided. | -| `NotImplementedError` | Neither `api_group` nor `api_version` is defined on the class. | -| `MissingRequiredArgumentError` | None of `name`, `yaml_file`, or `kind_dict` is provided. | -| `ResourceNotFoundError` | `ensure_exists=True` and the resource does not exist. | - -```python - from ocp_resources.namespace import Namespace - - # Basic creation - ns = Namespace(client=client, name="my-namespace") - - # From a YAML file - ns = Namespace(client=client, yaml_file="namespace.yaml") - - # From a dict - ns = Namespace(client=client, kind_dict={"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": "test"}}) - - # With labels and annotations - ns = Namespace(client=client, name="my-ns", label={"env": "test"}, annotations={"owner": "team-a"}) - - # Verify it already exists - ns = Namespace(client=client, name="default", ensure_exists=True) -``` - ---- - -### Context Manager Protocol - -`Resource` supports the Python context manager protocol for automatic creation and cleanup. - -```python - from ocp_resources.namespace import Namespace - - with Namespace(client=client, name="temp-ns") as ns: - # Resource is created on __enter__ - print(ns.name) - # Resource is deleted on __exit__ (if teardown=True) -``` - -| Method | Description | -|---|---| -| `__enter__()` | Calls `deploy(wait=self.wait_for_resource)`. Registers a `SIGINT` handler on the main thread to ensure cleanup on Ctrl+C. Returns `self`. | -| `__exit__(...)` | Calls `clean_up()` if `teardown=True`. Raises `ResourceTeardownError` if cleanup fails. | - -> **Tip:** Set `teardown=False` to prevent automatic deletion on context exit. - ---- - -### CRUD Methods - -#### `create` - -```python - def create( - self, - wait: bool = False, - exceptions_dict: dict[type[Exception], list[str]] = ..., - ) -> ResourceInstance | None -``` - -Create the resource on the cluster. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `wait` | `bool` | `False` | Wait for the resource to exist after creation. | -| `exceptions_dict` | `dict[type[Exception], list[str]]` | `DEFAULT_CLUSTER_RETRY_EXCEPTIONS \| PROTOCOL_ERROR_EXCEPTION_DICT` | Exceptions to retry on. | - -**Returns:** `ResourceInstance | None` - -**Raises:** `ValidationError` if `schema_validation_enabled=True` and the resource dict is invalid. - -```python - ns = Namespace(client=client, name="my-ns") - ns.create(wait=True) -``` - -#### `delete` - -```python - def delete( - self, - wait: bool = False, - timeout: int = 240, - body: dict[str, Any] | None = None, - ) -> bool -``` - -Delete the resource from the cluster. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `wait` | `bool` | `False` | Wait for the resource to be fully deleted. | -| `timeout` | `int` | `240` | Timeout in seconds when `wait=True`. | -| `body` | `dict[str, Any] \| None` | `None` | Optional delete options body. | - -**Returns:** `bool` — `True` if deleted or not found; `False` only if wait timed out. - -```python - ns.delete(wait=True, timeout=120) -``` - -#### `update` - -```python - def update(self, resource_dict: dict[str, Any]) -> None -``` - -Patch the resource with a merge-patch (`application/merge-patch+json`). Only updates the fields present in `resource_dict`. - -| Parameter | Type | Description | -|---|---|---| -| `resource_dict` | `dict[str, Any]` | Partial resource dictionary with fields to update. | - -> **Note:** Schema validation is **not** applied on `update()` because patches are partial and would fail full-schema validation. - -```python - ns.update(resource_dict={"metadata": {"labels": {"env": "staging"}}}) -``` - -#### `update_replace` - -```python - def update_replace(self, resource_dict: dict[str, Any]) -> None -``` - -Replace the full resource. Use this to **remove** existing fields (unlike `update()`, which only adds/modifies). - -| Parameter | Type | Description | -|---|---|---| -| `resource_dict` | `dict[str, Any]` | Complete resource dictionary to replace with. | - -**Raises:** `ValidationError` if `schema_validation_enabled=True` and the dict is invalid. - -```python - full_dict = ns.instance.to_dict() - full_dict["metadata"]["labels"] = {"new-label": "only"} - ns.update_replace(resource_dict=full_dict) -``` - ---- - -### Deploy / Clean Up - -#### `deploy` - -```python - def deploy(self, wait: bool = False) -> Self -``` - -Create the resource (respects `REUSE_IF_RESOURCE_EXISTS` environment variable). - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `wait` | `bool` | `False` | Wait for the resource after creation. | - -**Returns:** `Self` - -See [Environment Variables and Configuration](environment-variables.html) for `REUSE_IF_RESOURCE_EXISTS` details. - -#### `clean_up` - -```python - def clean_up(self, wait: bool = True, timeout: int | None = None) -> bool -``` - -Delete the resource (respects `SKIP_RESOURCE_TEARDOWN` environment variable). - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `wait` | `bool` | `True` | Wait for deletion to complete. | -| `timeout` | `int \| None` | `None` | Timeout in seconds. Defaults to `delete_timeout`. | - -**Returns:** `bool` — `True` if deleted successfully. - -See [Environment Variables and Configuration](environment-variables.html) for `SKIP_RESOURCE_TEARDOWN` details. - ---- - -### Query Methods and Properties - -#### `exists` - -```python - @property - def exists(self) -> ResourceInstance | None -``` - -Check if the resource exists on the cluster. - -**Returns:** `ResourceInstance` if found, `None` if not. - -```python - if ns.exists: - print("Namespace exists") -``` - -#### `instance` - -```python - @property - def instance(self) -> ResourceInstance -``` - -Get the live resource instance from the cluster. Retries on transient cluster errors. - -**Returns:** `ResourceInstance` - -**Raises:** `NotFoundError` if the resource does not exist. - -```python - resource_version = ns.instance.metadata.resourceVersion -``` - -#### `status` - -```python - @property - def status(self) -> str -``` - -Get `status.phase` from the resource instance. - -**Returns:** `str` — The status phase string (e.g., `"Running"`, `"Active"`, `"Pending"`). - -```python - print(ns.status) # "Active" -``` - -#### `labels` - -```python - @property - def labels(self) -> ResourceField -``` - -Get resource labels from `metadata.labels`. - -**Returns:** `ResourceField` - -```python - for key, value in ns.labels.items(): - print(f"{key}={value}") -``` - -#### `kind` - -```python - @ClassProperty - def kind(cls) -> str | None -``` - -Get the resource kind name derived from the class hierarchy. This is a **class-level property** — accessible on both the class and instances. - -**Returns:** `str | None` - -```python - from ocp_resources.namespace import Namespace - - print(Namespace.kind) # "Namespace" -``` - -#### `api` - -```python - @property - def api(self) -> ResourceInstance -``` - -Get the resource API object (shortcut for `full_api()` with no extra kwargs). - -**Returns:** `ResourceInstance` - -#### `full_api` - -```python - def full_api(self, **kwargs: Any) -> ResourceInstance -``` - -Get the resource API object with optional filtering kwargs. - -| Keyword Argument | Description | -|---|---| -| `pretty` | Pretty-print output. | -| `_continue` | Continuation token for list pagination. | -| `field_selector` | Filter by field. | -| `label_selector` | Filter by label. | -| `limit` | Maximum number of results. | -| `resource_version` | Filter by resource version. | -| `timeout_seconds` | Request timeout. | -| `watch` | Enable watch mode. | - -**Returns:** `ResourceInstance` - ---- - -### Class Method: `get` - -```python - @classmethod - def get( - cls, - client: DynamicClient | None = None, - dyn_client: DynamicClient | None = None, - config_file: str = "", - singular_name: str = "", - exceptions_dict: dict[type[Exception], list[str]] = ..., - raw: bool = False, - context: str | None = None, - *args: Any, - **kwargs: Any, - ) -> Generator[Any, None, None] -``` - -List resources of this kind from the cluster. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `client` | `DynamicClient \| None` | `None` | Kubernetes client. | -| `dyn_client` | `DynamicClient \| None` | `None` | Deprecated alias for `client`. | -| `config_file` | `str` | `""` | Path to kubeconfig. Deprecated; pass `client`. | -| `singular_name` | `str` | `""` | Singular resource name for disambiguation. | -| `exceptions_dict` | `dict` | `DEFAULT_CLUSTER_RETRY_EXCEPTIONS` | Exceptions to retry. | -| `raw` | `bool` | `False` | If `True`, yield raw `ResourceInstance` objects instead of wrapper instances. | -| `context` | `str \| None` | `None` | Kubeconfig context. Deprecated; pass `client`. | -| `**kwargs` | | | Passed through to the API (e.g., `label_selector`, `field_selector`). | - -**Returns:** `Generator` of resource instances. - -> **Note:** For `Resource` (cluster-scoped), yielded objects are constructed with `name` only. For `NamespacedResource`, yielded objects include both `name` and `namespace`. - -```python - from ocp_resources.namespace import Namespace - - for ns in Namespace.get(client=client): - print(ns.name) - - # With label selector - for ns in Namespace.get(client=client, label_selector="env=production"): - print(ns.name) - - # Raw mode - for raw_ns in Namespace.get(client=client, raw=True): - print(raw_ns.metadata.name, raw_ns.status.phase) -``` - -See [Querying and Watching Resources](querying-resources.html) for advanced list/filter patterns. - ---- - -### Static Method: `get_all_cluster_resources` - -```python - @staticmethod - def get_all_cluster_resources( - client: DynamicClient | None = None, - config_file: str = "", - context: str | None = None, - config_dict: dict[str, Any] | None = None, - *args: Any, - **kwargs: Any, - ) -> Generator[ResourceField, None, None] -``` - -Yield all resources across **all** API groups in the cluster. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `client` | `DynamicClient \| None` | `None` | Kubernetes client. | -| `**kwargs` | | | Passed to the API (e.g., `label_selector`). | - -**Yields:** `ResourceField` - -```python - for resource in Resource.get_all_cluster_resources(client=client, label_selector="app=myapp"): - print(f"{resource.kind}: {resource.metadata.name}") -``` - ---- - -### Wait Methods - -#### `wait` - -```python - def wait(self, timeout: int = 240, sleep: int = 1) -> None -``` - -Wait until the resource exists on the cluster. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `timeout` | `int` | `240` | Maximum wait time in seconds. | -| `sleep` | `int` | `1` | Sleep interval between retries. | - -**Raises:** `TimeoutExpiredError` if the resource does not exist within the timeout. - -#### `wait_deleted` - -```python - def wait_deleted(self, timeout: int = 240) -> bool -``` - -Wait until the resource no longer exists. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `timeout` | `int` | `240` | Maximum wait time in seconds. | - -**Returns:** `bool` — `True` if deleted, `False` if timed out. - -#### `wait_for_status` - -```python - def wait_for_status( - self, - status: str, - timeout: int = 240, - stop_status: str | None = None, - sleep: int = 1, - exceptions_dict: dict[type[Exception], list[str]] = ..., - ) -> None -``` - -Wait for `status.phase` to reach the expected value. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `status` | `str` | — | Expected status phase string. | -| `timeout` | `int` | `240` | Maximum wait time in seconds. | -| `stop_status` | `str \| None` | `None` | Stop and fail immediately if this status is reached. Defaults to `Status.FAILED`. | -| `sleep` | `int` | `1` | Sleep interval between retries. | -| `exceptions_dict` | `dict` | `PROTOCOL_ERROR_EXCEPTION_DICT \| DEFAULT_CLUSTER_RETRY_EXCEPTIONS` | Exceptions to retry. | - -**Raises:** `TimeoutExpiredError` if the desired status is not reached. - -```python - from ocp_resources.pod import Pod - - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120) -``` - -#### `wait_for_condition` - -```python - def wait_for_condition( - self, - condition: str, - status: str, - timeout: int = 300, - sleep_time: int = 1, - reason: str | None = None, - message: str = "", - stop_condition: str | None = None, - stop_status: str = "True", - ) -> None -``` - -Wait for a specific condition to reach the desired state. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `condition` | `str` | — | Condition type name (e.g., `"Ready"`, `"Available"`). | -| `status` | `str` | — | Expected condition status (e.g., `"True"`, `"False"`). | -| `timeout` | `int` | `300` | Maximum wait time in seconds. | -| `sleep_time` | `int` | `1` | Interval between retries. | -| `reason` | `str \| None` | `None` | Expected condition reason. If set, must match exactly. | -| `message` | `str` | `""` | Expected substring within the condition message. | -| `stop_condition` | `str \| None` | `None` | Condition type that, if matched, stops the wait and fails immediately. | -| `stop_status` | `str` | `"True"` | Status for `stop_condition` matching. | - -**Raises:** - -| Exception | Condition | -|---|---| -| `TimeoutExpiredError` | Condition not met within timeout. | -| `ConditionError` | `stop_condition` is detected with matching `stop_status`. | - -```python - ns.wait_for_condition( - condition="Ready", - status="True", - timeout=60, - ) -``` - -See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for more patterns. - -#### `wait_for_conditions` - -```python - def wait_for_conditions(self) -> None -``` - -Wait for the resource to have any conditions populated in its status. Uses a 30-second timeout. - ---- - -### Serialization - -#### `to_dict` - -```python - def to_dict(self) -> None -``` - -Populate `self.res` with the intended dict representation of the resource. Called automatically before `create()`. Override this in subclasses to add resource-specific fields. - -> **Note:** If `kind_dict` or `yaml_file` was provided, `to_dict()` uses those directly instead of building from individual parameters. - -#### `to_yaml` - -```python - def to_yaml(self) -> str -``` - -**Returns:** `str` — YAML string representation of the resource dict. - -```python - print(ns.to_yaml()) -``` - ---- - -### Watch / Events - -#### `watcher` - -```python - def watcher( - self, - timeout: int, - resource_version: str = "", - ) -> Generator[dict[str, Any], None, None] -``` - -Watch for changes to this specific resource. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `timeout` | `int` | — | Watch duration in seconds. | -| `resource_version` | `str` | `""` | Only return events after this version. Defaults to the version at resource creation time. | - -**Yields:** Event dicts with keys `type` (`"ADDED"`, `"MODIFIED"`, `"DELETED"`), `raw_object`, and `object`. - -```python - for event in ns.watcher(timeout=30): - print(event["type"], event["object"].metadata.name) -``` - -#### `events` - -```python - def events( - self, - name: str = "", - label_selector: str = "", - field_selector: str = "", - resource_version: str = "", - timeout: int = 240, - ) -> Generator[Any, Any, None] -``` - -Get Kubernetes events related to this resource. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `name` | `str` | `""` | Filter by event name. | -| `label_selector` | `str` | `""` | Comma-separated `key=value` label filters. | -| `field_selector` | `str` | `""` | Additional field selectors (auto-prefixed with `involvedObject.name==`). | -| `resource_version` | `str` | `""` | Filter events by resource version. | -| `timeout` | `int` | `240` | Timeout in seconds. | - -**Yields:** `Event` objects. - -```python - for event in pod.events(field_selector="type==Warning", timeout=10): - print(event.object) -``` - ---- - -### Validation - -#### `validate` - -```python - def validate(self) -> None -``` - -Validate `self.res` against the OpenAPI schema for this resource kind. Called automatically during `create()` and `update_replace()` when `schema_validation_enabled=True`. - -**Raises:** `ValidationError` if the resource dict is invalid. - -```python - pod = Pod(client=client, name="test", namespace="default") - pod.to_dict() - pod.validate() # Raises ValidationError on invalid spec -``` - -#### `validate_dict` (classmethod) - -```python - @classmethod - def validate_dict(cls, resource_dict: dict[str, Any]) -> None -``` - -Validate an arbitrary resource dictionary against the schema without creating a resource instance. - -| Parameter | Type | Description | -|---|---|---| -| `resource_dict` | `dict[str, Any]` | Complete resource dictionary. | - -**Raises:** `ValidationError` if validation fails. - -```python - from ocp_resources.pod import Pod - - Pod.validate_dict({ - "apiVersion": "v1", - "kind": "Pod", - "metadata": {"name": "test"}, - "spec": {"containers": [{"name": "web", "image": "nginx"}]}, - }) -``` - -See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for full validation guide. - ---- - -### API Request - -#### `api_request` - -```python - def api_request( - self, - method: str, - action: str, - url: str, - retry_params: dict[str, int] | None = None, - **params: Any, - ) -> dict[str, Any] -``` - -Send a raw HTTP request to the resource's API endpoint. Used internally for subresource actions (e.g., VirtualMachine start/stop). - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `method` | `str` | — | HTTP method (`"GET"`, `"PUT"`, `"POST"`, etc.). | -| `action` | `str` | — | Subresource action to append to the URL (e.g., `"start"`, `"stop"`). | -| `url` | `str` | — | Base URL of the resource. | -| `retry_params` | `dict[str, int] \| None` | `None` | Dict with `timeout` and `sleep_time` keys for retry behavior. | -| `**params` | | | Additional params passed to the HTTP request. | - -**Returns:** `dict[str, Any]` — Parsed JSON response, or raw response data if not valid JSON. - ---- - -### Utility Methods - -#### `retry_cluster_exceptions` (static) - -```python - @staticmethod - def retry_cluster_exceptions( - func: Callable, - exceptions_dict: dict[type[Exception], list[str]] = DEFAULT_CLUSTER_RETRY_EXCEPTIONS, - timeout: int = 10, - sleep_time: int = 1, - **kwargs: Any, - ) -> Any -``` - -Retry a callable on transient cluster errors. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `func` | `Callable` | — | Function to call. | -| `exceptions_dict` | `dict` | `DEFAULT_CLUSTER_RETRY_EXCEPTIONS` | Map of exception types to message substrings to match. | -| `timeout` | `int` | `10` | Total retry timeout in seconds. | -| `sleep_time` | `int` | `1` | Sleep between retries. | -| `**kwargs` | | | Passed to `func`. | - -**Returns:** The return value of `func`. - -**Raises:** The last exception if timeout is reached. - -#### `get_condition_message` - -```python - def get_condition_message( - self, - condition_type: str, - condition_status: str = "", - ) -> str -``` - -Get the message for a specific condition. - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `condition_type` | `str` | — | Condition type name. | -| `condition_status` | `str` | `""` | If set, only return the message when the condition status matches. | - -**Returns:** `str` — The condition message, or `""` if not found or status doesn't match. - -#### `hash_resource_dict` - -```python - def hash_resource_dict(self, resource_dict: dict[Any, Any]) -> dict[Any, Any] -``` - -Replace sensitive fields (defined by `keys_to_hash`) with `"*******"` for logging. - -| Parameter | Type | Description | -|---|---|---| -| `resource_dict` | `dict` | The resource dict to hash. | - -**Returns:** `dict` — A copy with sensitive values replaced. - -> **Tip:** Override the `keys_to_hash` property in subclasses to define which fields to mask. - -#### `keys_to_hash` - -```python - @property - def keys_to_hash(self) -> list[str] -``` - -List of key paths to mask in logs. Uses `>` as separator, `[]` for list elements. - -**Returns:** `list[str]` — Default: `[]` (no hashing). - -```python - # Example override in a Secret subclass: - @property - def keys_to_hash(self): - return ["data", "stringData"] -``` - ---- - -### Inner Classes - -#### `Resource.Status` - -Pre-defined status phase constants (inherited from `ResourceConstants`). - -| Constant | Value | -|---|---| -| `Status.SUCCEEDED` | `"Succeeded"` | -| `Status.FAILED` | `"Failed"` | -| `Status.DELETING` | `"Deleting"` | -| `Status.DEPLOYED` | `"Deployed"` | -| `Status.PENDING` | `"Pending"` | -| `Status.COMPLETED` | `"Completed"` | -| `Status.RUNNING` | `"Running"` | -| `Status.READY` | `"Ready"` | -| `Status.TERMINATING` | `"Terminating"` | -| `Status.ERROR` | `"Error"` | -| `Status.ACTIVE` | `"Active"` | -| `Status.SCHEDULING` | `"Scheduling"` | -| `Status.CRASH_LOOPBACK_OFF` | `"CrashLoopBackOff"` | -| `Status.IMAGE_PULL_BACK_OFF` | `"ImagePullBackOff"` | - -```python - pod.wait_for_status(status=Pod.Status.RUNNING) -``` - -#### `Resource.Condition` - -Pre-defined condition type and status constants. - -| Constant | Value | -|---|---| -| `Condition.READY` | `"Ready"` | -| `Condition.AVAILABLE` | `"Available"` | -| `Condition.DEGRADED` | `"Degraded"` | -| `Condition.PROGRESSING` | `"Progressing"` | -| `Condition.UPGRADEABLE` | `"Upgradeable"` | -| `Condition.Status.TRUE` | `"True"` | -| `Condition.Status.FALSE` | `"False"` | -| `Condition.Status.UNKNOWN` | `"Unknown"` | - -```python - ns.wait_for_condition( - condition=Resource.Condition.READY, - status=Resource.Condition.Status.TRUE, - ) -``` - -#### `Resource.ApiGroup` - -Pre-defined API group string constants. A selection of commonly used values: - -| Constant | Value | -|---|---| -| `ApiGroup.APPS` | `"apps"` | -| `ApiGroup.BATCH` | `"batch"` | -| `ApiGroup.NETWORKING_K8S_IO` | `"networking.k8s.io"` | -| `ApiGroup.RBAC_AUTHORIZATION_K8S_IO` | `"rbac.authorization.k8s.io"` | -| `ApiGroup.STORAGE_K8S_IO` | `"storage.k8s.io"` | -| `ApiGroup.CONFIG_OPENSHIFT_IO` | `"config.openshift.io"` | -| `ApiGroup.KUBEVIRT_IO` | `"kubevirt.io"` | -| `ApiGroup.CDI_KUBEVIRT_IO` | `"cdi.kubevirt.io"` | -| `ApiGroup.ROUTE_OPENSHIFT_IO` | `"route.openshift.io"` | -| `ApiGroup.MACHINE_OPENSHIFT_IO` | `"machine.openshift.io"` | - -> **Note:** Over 100 API group constants are available. Use IDE auto-complete to discover all values. - -#### `Resource.ApiVersion` - -| Constant | Value | -|---|---| -| `ApiVersion.V1` | `"v1"` | -| `ApiVersion.V1BETA1` | `"v1beta1"` | -| `ApiVersion.V1ALPHA1` | `"v1alpha1"` | -| `ApiVersion.V1ALPHA3` | `"v1alpha3"` | - ---- - -## `NamespacedResource` - -```python - from ocp_resources.resource import NamespacedResource -``` - -Base class for **namespace-scoped** resources (e.g., `Pod`, `Deployment`, `Service`, `ConfigMap`). Extends `Resource` with namespace awareness. - -### Constructor - -```python - NamespacedResource( - client=client, - name="my-pod", - namespace="my-namespace", - ) -``` - -All parameters from [`Resource`](#constructor) are accepted, plus: - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `namespace` | `str \| None` | `None` | Kubernetes namespace. Required unless `yaml_file` or `kind_dict` is provided. | - -**Raises:** `MissingRequiredArgumentError` if neither (`name` + `namespace`) nor `yaml_file` / `kind_dict` is provided. - -```python - from ocp_resources.pod import Pod - - pod = Pod( - client=client, - name="nginx", - namespace="default", - label={"app": "web"}, - ) -``` - -### Overridden Methods - -#### `instance` - -```python - @property - def instance(self) -> ResourceInstance -``` - -Get the live resource instance, scoped to `self.namespace`. - -**Returns:** `ResourceInstance` - -#### `to_dict` - -```python - def to_dict(self) -> None -``` - -Populates `self.res` and sets `metadata.namespace`. If using `yaml_file` or `kind_dict`, reads the namespace from the YAML/dict. - -**Raises:** `MissingRequiredArgumentError` if namespace is still not set after processing. - -#### `get` (classmethod) - -Behaves like `Resource.get()`, but yields instances constructed with both `name` and `namespace`. - -```python - from ocp_resources.pod import Pod - - for pod in Pod.get(client=client, namespace="default"): - print(f"{pod.namespace}/{pod.name}") - - # With label selector - for pod in Pod.get(client=client, namespace="default", label_selector="app=web"): - print(pod.name) -``` - ---- - -## `ResourceEditor` - -```python - from ocp_resources.resource import ResourceEditor -``` - -Temporarily patch resources and automatically restore original values. Designed for test scenarios. - -### Constructor - -```python - ResourceEditor( - patches: dict[Any, Any], - action: str = "update", - user_backups: dict[Any, Any] | None = None, - ) -``` - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `patches` | `dict` | — | Map of `{Resource: patch_dict}`. | -| `action` | `str` | `"update"` | `"update"` for merge-patch, `"replace"` for full replacement. | -| `user_backups` | `dict \| None` | `None` | Pre-computed backup dicts. If provided, skips automatic backup creation. | - -### Context Manager Usage - -```python - from ocp_resources.resource import ResourceEditor - from ocp_resources.namespace import Namespace - - ns = Namespace(client=client, name="my-ns", ensure_exists=True) - - with ResourceEditor(patches={ns: {"metadata": {"labels": {"temporary": "true"}}}}) as editor: - # Labels are patched - assert ns.instance.metadata.labels.temporary == "true" - # Labels are restored to original values -``` - -### Methods - -| Method | Signature | Description | -|---|---|---| -| `update` | `update(backup_resources: bool = False) -> None` | Apply patches. If `backup_resources=True`, back up original values first. | -| `restore` | `restore() -> None` | Restore all backed-up values. | - -### Properties - -| Property | Type | Description | -|---|---|---| -| `backups` | `dict[Any, Any]` | Backed-up original values for each patched resource. | -| `patches` | `dict[Any, Any]` | The patches dict from the constructor. | - -> **Warning:** The `DynamicClient` used to obtain the resource objects must have sufficient privileges to patch and replace resources. - -See [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html) for detailed patterns. - ---- - -## `ResourceList` - -```python - from ocp_resources.resource import ResourceList -``` - -Create and manage N copies of a cluster-scoped resource with indexed names. - -### Constructor - -```python - ResourceList( - resource_class: type[Resource], - num_resources: int, - client: DynamicClient, - **kwargs: Any, - ) -``` - -| Parameter | Type | Description | -|---|---|---| -| `resource_class` | `type[Resource]` | The resource class to instantiate. | -| `num_resources` | `int` | Number of resource copies to create. | -| `client` | `DynamicClient` | Kubernetes client. | -| `**kwargs` | | Passed to each resource constructor. Must include `name` (used as base name). | - -Resources are named `{name}-1`, `{name}-2`, ..., `{name}-N`. - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.resource import ResourceList - - namespaces = ResourceList( - resource_class=Namespace, - num_resources=3, - client=client, - name="test-ns", - ) - - with namespaces: - # Creates test-ns-1, test-ns-2, test-ns-3 - for ns in namespaces: - print(ns.name) - # All three namespaces are deleted -``` - -### Methods (inherited from `BaseResourceList`) - -| Method | Signature | Returns | Description | -|---|---|---|---| -| `deploy` | `deploy(wait=False)` | `list[Resource]` | Deploy all resources. | -| `clean_up` | `clean_up(wait=True)` | `bool` | Delete all resources in reverse order. | -| `__len__` | `__len__()` | `int` | Number of resources. | -| `__getitem__` | `__getitem__(index)` | `Resource` | Access by index. | -| `__iter__` | `__iter__()` | `Generator` | Iterate over resources. | - ---- - -## `NamespacedResourceList` - -```python - from ocp_resources.resource import NamespacedResourceList -``` - -Create one instance of a namespaced resource in each namespace from a `ResourceList`. - -### Constructor - -```python - NamespacedResourceList( - resource_class: type[NamespacedResource], - namespaces: ResourceList, - client: DynamicClient, - **kwargs: Any, - ) -``` - -| Parameter | Type | Description | -|---|---|---| -| `resource_class` | `type[NamespacedResource]` | The namespaced resource class (e.g., `Pod`). | -| `namespaces` | `ResourceList` | A `ResourceList` of `Namespace` resources. | -| `client` | `DynamicClient` | Kubernetes client. | -| `**kwargs` | | Passed to each resource constructor. Must include `name`. | - -**Raises:** `TypeError` if any resource in `namespaces` is not a `Namespace`. - -```python - from ocp_resources.namespace import Namespace - from ocp_resources.pod import Pod - from ocp_resources.resource import ResourceList, NamespacedResourceList - - namespaces = ResourceList(resource_class=Namespace, num_resources=2, client=client, name="test-ns") - - pods = NamespacedResourceList( - resource_class=Pod, - namespaces=namespaces, - client=client, - name="nginx", - ) - - with namespaces: - with pods: - # Creates nginx in test-ns-1 and test-ns-2 - for pod in pods: - print(f"{pod.namespace}/{pod.name}") -``` - -See [Managing Bulk Resources with ResourceList](managing-resource-lists.html) for full usage patterns. - ---- - -## `KubeAPIVersion` - -```python - from ocp_resources.resource import KubeAPIVersion -``` - -Implements [Kubernetes API versioning](https://kubernetes.io/docs/concepts/overview/kubernetes-api/#api-versioning) with comparison operators. Extends `packaging.version.Version`. - -### Constructor - -```python - KubeAPIVersion(vstring: str) -``` - -| Parameter | Type | Description | -|---|---|---| -| `vstring` | `str` | Version string (e.g., `"v1"`, `"v1beta1"`, `"v1alpha2"`). | - -**Raises:** `ValueError` if the version string does not conform to Kubernetes versioning. - -### Ordering - -Versions are ordered: `v1alpha1 < v1alpha2 < v1beta1 < v1beta2 < v1 < v2`. - -```python - assert KubeAPIVersion("v1") > KubeAPIVersion("v1beta1") - assert KubeAPIVersion("v1beta1") > KubeAPIVersion("v1alpha1") - assert KubeAPIVersion("v1") == KubeAPIVersion("v1") -``` - ---- - -## Exceptions - -All exceptions are importable from `ocp_resources.exceptions`. - -```python - from ocp_resources.exceptions import ( - MissingRequiredArgumentError, - ResourceTeardownError, - ValidationError, - ConditionError, - ) -``` - -| Exception | Raised By | Description | -|---|---|---| -| `MissingRequiredArgumentError` | `Resource.__init__`, `NamespacedResource.__init__`, `NamespacedResource._base_body` | Required parameters (`name`, `namespace`) not provided. | -| `ResourceTeardownError` | `Resource.__exit__` | `clean_up()` returned `False` during context manager exit. | -| `ValidationError` | `validate()`, `validate_dict()`, `create()`, `update_replace()` | Resource dict fails OpenAPI schema validation. Has `message`, `path`, and `schema_error` attributes. | -| `ConditionError` | `wait_for_condition()` | A `stop_condition` was detected during condition waiting. | -| `MissingResourceResError` | `Resource._base_body` | Deprecated. `self.res` is empty after `_base_body()`. | - ---- - -## Timeout Constants - -Available from `ocp_resources.utils.constants`: - -```python - from ocp_resources.utils.constants import ( - TIMEOUT_1SEC, # 1 - TIMEOUT_5SEC, # 5 - TIMEOUT_10SEC, # 10 - TIMEOUT_30SEC, # 30 - TIMEOUT_1MINUTE, # 60 - TIMEOUT_2MINUTES, # 120 - TIMEOUT_4MINUTES, # 240 - TIMEOUT_10MINUTES, # 600 - ) -``` - ---- - -## Module-Level Functions - -### `replace_key_with_hashed_value` - -```python - from ocp_resources.resource import replace_key_with_hashed_value - - replace_key_with_hashed_value(resource_dict: dict, key_name: str) -> dict -``` - -Recursively replace the value at a key path with `"*******"`. Supports dict paths (`a>b>c`) and list paths (`a>b[]>c`). - -| Parameter | Type | Description | -|---|---|---| -| `resource_dict` | `dict` | The nested dictionary. | -| `key_name` | `str` | Key path using `>` as separator, `[]` for list elements. | - -**Returns:** `dict` — A deep copy with the value replaced. - -```python - data = {"spec": {"secret": {"token": "sensitive"}}} - hashed = replace_key_with_hashed_value(data, "spec>secret>token") - # {"spec": {"secret": {"token": "*******"}}} - - # With list path - data = {"spec": {"containers": [{"env": [{"value": "secret"}]}]}} - hashed = replace_key_with_hashed_value(data, "spec>containers[]>env[]>value") -``` - -## Related Pages - -- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Querying and Watching Resources](querying-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Environment Variables and Configuration](environment-variables.html) - - ---- - -Source: environment-variables.md - -# Environment Variables and Configuration - -This page documents all environment variables recognized by `openshift-python-wrapper` at runtime. These variables control logging behavior, resource lifecycle operations, proxy configuration, cluster connectivity, and sensitive data hashing. - -## Quick Reference - -| Environment Variable | Purpose | Default | -|---|---|---| -| `KUBECONFIG` | Path to kubeconfig file | `~/.kube/config` | -| `HTTPS_PROXY` | HTTPS proxy URL for cluster connections | _(unset)_ | -| `HTTP_PROXY` | HTTP proxy URL for cluster connections | _(unset)_ | -| `OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL` | Logging verbosity level | `INFO` | -| `OPENSHIFT_PYTHON_WRAPPER_LOG_FILE` | Path to log output file | `""` (stdout) | -| `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` | Enable/disable hashing sensitive data in logs | `true` | -| `REUSE_IF_RESOURCE_EXISTS` | Skip resource creation if already exists | _(unset)_ | -| `SKIP_RESOURCE_TEARDOWN` | Skip resource deletion during teardown | _(unset)_ | - ---- - -## Cluster Connection - -### `KUBECONFIG` - -| Property | Value | -|---|---| -| **Type** | `str` | -| **Default** | `~/.kube/config` | -| **Used by** | `get_client()` | -| **Source** | `ocp_resources/resource.py` | - -Path to the kubeconfig file used when creating a Kubernetes `DynamicClient`. Read when no explicit `config_file`, `config_dict`, `host`/`token`, or `username`/`password` arguments are passed to `get_client()`. - -> **Note:** The standard `kubernetes` Python client reads `KUBECONFIG` at import time. If you set this variable in code (after import), `openshift-python-wrapper` handles it by explicitly passing the value to the client constructor. See [Connecting to Clusters](connecting-to-clusters.html) for all connection options. - -```bash - export KUBECONFIG=/path/to/my/kubeconfig -``` - -```python - from ocp_resources.resource import get_client - - # Automatically uses $KUBECONFIG, or falls back to ~/.kube/config - client = get_client() -``` - ---- - -### `HTTPS_PROXY` / `HTTP_PROXY` - -| Property | Value | -|---|---| -| **Type** | `str` | -| **Default** | _(unset — no proxy)_ | -| **Used by** | `get_client()` | -| **Source** | `ocp_resources/resource.py` | - -Sets the proxy on the Kubernetes client configuration when no proxy is already configured on the `client_configuration` object. `HTTPS_PROXY` takes precedence over `HTTP_PROXY` when both are set. - -```bash - export HTTPS_PROXY=http://proxy.example.com:8080 -``` - -```python - from ocp_resources.resource import get_client - - # Proxy is automatically applied from environment - client = get_client() -``` - -> **Tip:** If you pass a `client_configuration` object that already has a `.proxy` set, the environment variables are ignored. - -**Precedence order:** - -1. Explicit `client_configuration.proxy` (if already set) -2. `HTTPS_PROXY` environment variable -3. `HTTP_PROXY` environment variable - ---- - -## Logging - -### `OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL` - -| Property | Value | -|---|---| -| **Type** | `str` | -| **Default** | `INFO` | -| **Valid values** | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | -| **Used by** | `Resource._set_logger()` | -| **Source** | `ocp_resources/resource.py` | - -Controls the log level for each resource instance's logger. The logger is created per-resource using the `simple_logger` library. - -```bash - export OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG -``` - -```python - import os - - os.environ["OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL"] = "DEBUG" - - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - pod = Pod(name="my-pod", namespace="default", client=client) - # Logger on this Pod instance now uses DEBUG level -``` - ---- - -### `OPENSHIFT_PYTHON_WRAPPER_LOG_FILE` - -| Property | Value | -|---|---| -| **Type** | `str` | -| **Default** | `""` (empty string — logs to stdout) | -| **Used by** | `Resource._set_logger()` | -| **Source** | `ocp_resources/resource.py` | - -Redirects resource log output to a file. When unset or empty, logs are written to stdout. - -```bash - export OPENSHIFT_PYTHON_WRAPPER_LOG_FILE=/var/log/ocp-wrapper.log -``` - -```python - import os - - os.environ["OPENSHIFT_PYTHON_WRAPPER_LOG_FILE"] = "/tmp/ocp-wrapper.log" - - from ocp_resources.namespace import Namespace - from ocp_resources.resource import get_client - - client = get_client() - ns = Namespace(name="test-ns", client=client) - # All log output for this resource is written to /tmp/ocp-wrapper.log -``` - ---- - -## Sensitive Data Hashing - -### `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` - -| Property | Value | -|---|---| -| **Type** | `str` | -| **Default** | `"true"` | -| **Valid values** | `"true"`, `"false"` | -| **Used by** | `Resource.hash_resource_dict()` | -| **Source** | `ocp_resources/resource.py` | - -Controls whether sensitive fields in resource dictionaries are replaced with `"*******"` when logged. When set to `"false"`, sensitive data is logged in cleartext. - -Hashing is applied to fields declared in each resource class's `keys_to_hash` property. The following built-in resources define sensitive keys: - -| Resource Class | Hashed Fields | -|---|---| -| `Secret` | `data`, `stringData` | -| `ConfigMap` | `data`, `binaryData` | -| `SealedSecret` | `spec>data`, `spec>encryptedData` | -| `VirtualMachine` | `spec>template>spec>volumes[]>cloudInitNoCloud>userData` | - -> **Warning:** Setting this to `"false"` causes sensitive data (e.g., secrets, tokens) to appear in log output. Use only for debugging in secure environments. - -```bash - # Disable hashing to see raw values in logs (debug only) - export OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA=false -``` - -```python - import os - - os.environ["OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA"] = "false" - - from ocp_resources.secret import Secret - from ocp_resources.resource import get_client - - client = get_client() - secret = Secret(name="my-secret", namespace="default", client=client) - # Logs will now show raw secret data instead of "*******" -``` - -> **Note:** Hashing also depends on the `hash_log_data` constructor parameter on each resource instance. Both must be enabled for hashing to occur. The `hash_log_data` parameter defaults to `True`. See [Resource and NamespacedResource API](resource-api.html) for constructor details. - -**Interaction with `hash_log_data` parameter:** - -| `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` | `hash_log_data` param | Result | -|---|---|---| -| `"true"` (default) | `True` (default) | Sensitive fields hashed | -| `"true"` | `False` | Sensitive fields **not** hashed | -| `"false"` | `True` | Sensitive fields **not** hashed | -| `"false"` | `False` | Sensitive fields **not** hashed | - ---- - -## Resource Reuse (Skip Creation) - -### `REUSE_IF_RESOURCE_EXISTS` - -| Property | Value | -|---|---| -| **Type** | `str` (YAML dict syntax) | -| **Default** | _(unset — no resources skipped)_ | -| **Used by** | `Resource.deploy()` | -| **Source** | `ocp_resources/resource.py` | - -When set, `deploy()` checks whether the target resource already exists on the cluster. If a match is found, the existing resource is returned without creating a new one. This is intended for debugging and iterative development workflows. - -> **Warning:** Spaces are significant in the value syntax. The value is parsed as YAML. - -**Value format:** - -``` - {: {: }} -``` - -**Matching rules:** - -| Pattern | Behavior | -|---|---| -| `{Pod: {}}` | Skip creation for **all** Pods (match by kind only) | -| `{Pod: {my-pod:}}` | Skip creation for Pod named `my-pod` in **any** namespace | -| `{Pod: {my-pod: my-ns}}` | Skip creation for Pod named `my-pod` in namespace `my-ns` only | -| `{Kind1: {}, Kind2: {name: ns}}` | Multiple resource patterns combined | - -**Examples:** - -```bash - # Skip all Pod creation if the pod already exists - export REUSE_IF_RESOURCE_EXISTS="{Pod: {}}" - - # Skip specific pod in specific namespace - export REUSE_IF_RESOURCE_EXISTS="{Pod: {my-pod: my-namespace}}" - - # Skip namespace and pod creation - export REUSE_IF_RESOURCE_EXISTS="{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}" -``` - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - - # If REUSE_IF_RESOURCE_EXISTS is set and a matching Pod exists, - # deploy() returns the existing resource without calling create() - pod = Pod( - name="my-pod", - namespace="my-namespace", - client=client, - containers=[{"name": "test", "image": "nginx"}], - ) - pod.deploy() # Skips creation if resource matches the env var pattern -``` - -> **Note:** The resource must actually exist on the cluster for the skip to take effect. If the resource does not exist, `deploy()` proceeds with normal creation. - ---- - -## Skip Teardown - -### `SKIP_RESOURCE_TEARDOWN` - -| Property | Value | -|---|---| -| **Type** | `str` (YAML dict syntax) | -| **Default** | _(unset — no resources skipped)_ | -| **Used by** | `Resource.clean_up()` | -| **Source** | `ocp_resources/resource.py` | - -When set, `clean_up()` skips deletion for matching resources and returns `True` without calling `delete()`. This is intended for debugging — preserving resources on the cluster after tests finish. - -> **Warning:** Spaces are significant in the value syntax. The value is parsed as YAML. - -**Value format:** - -Uses the same YAML dict syntax as [`REUSE_IF_RESOURCE_EXISTS`](#reuse_if_resource_exists). - -**Matching rules:** - -| Pattern | Behavior | -|---|---| -| `{Pod: {}}` | Skip teardown for **all** Pods | -| `{Pod: {my-pod:}}` | Skip teardown for Pod named `my-pod` in **any** namespace | -| `{Pod: {my-pod: my-ns}}` | Skip teardown for Pod named `my-pod` in namespace `my-ns` only | -| `{Kind1: {}, Kind2: {name: ns}}` | Multiple resource patterns combined | - -**Examples:** - -```bash - # Keep all Namespaces after test run - export SKIP_RESOURCE_TEARDOWN="{Namespace: {}}" - - # Keep specific resources - export SKIP_RESOURCE_TEARDOWN="{Namespace: {test-ns:}, Pod: {debug-pod: test-ns}}" -``` - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - pod = Pod( - name="debug-pod", - namespace="test-ns", - client=client, - containers=[{"name": "test", "image": "nginx"}], - ) - pod.deploy() - - # If SKIP_RESOURCE_TEARDOWN is set with a matching pattern, - # clean_up() returns True without deleting the resource - pod.clean_up() # Resource remains on the cluster -``` - -> **Tip:** Use `REUSE_IF_RESOURCE_EXISTS` and `SKIP_RESOURCE_TEARDOWN` together for a fast debug loop: skip creation if a resource already exists, and skip teardown so it persists between runs. - -```bash - export REUSE_IF_RESOURCE_EXISTS="{Pod: {debug-pod: test-ns}}" - export SKIP_RESOURCE_TEARDOWN="{Pod: {debug-pod: test-ns}}" -``` - ---- - -## Combined Usage Patterns - -### Debug iteration loop - -Set both skip variables to avoid re-creating and tearing down resources between test runs: - -```bash - export REUSE_IF_RESOURCE_EXISTS="{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}" - export SKIP_RESOURCE_TEARDOWN="{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}" -``` - -### Full debug logging with unhashed data - -```bash - export OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG - export OPENSHIFT_PYTHON_WRAPPER_LOG_FILE=/tmp/debug.log - export OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA=false -``` - -> **Warning:** This combination logs sensitive data in plaintext. Do not use in production or shared environments. - -### Custom kubeconfig with proxy - -```bash - export KUBECONFIG=/path/to/custom/kubeconfig - export HTTPS_PROXY=http://corporate-proxy.example.com:3128 -``` - ---- - -## Programmatic Configuration Alternatives - -Several behaviors controlled by environment variables can also be set programmatically via constructor parameters or function arguments. See [Resource and NamespacedResource API](resource-api.html) for full constructor parameter documentation. - -| Environment Variable | Programmatic Alternative | -|---|---| -| `KUBECONFIG` | `get_client(config_file="/path/to/kubeconfig")` | -| `HTTPS_PROXY` / `HTTP_PROXY` | `client_configuration.proxy = "http://proxy:port"` | -| `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` | `Resource(hash_log_data=False, ...)` constructor parameter | - -> **Note:** `OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL`, `OPENSHIFT_PYTHON_WRAPPER_LOG_FILE`, `REUSE_IF_RESOURCE_EXISTS`, and `SKIP_RESOURCE_TEARDOWN` can **only** be configured via environment variables. - -## Related Pages - -- [Connecting to Clusters](connecting-to-clusters.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Resource and NamespacedResource API](resource-api.html) -- [Common Resource Patterns](common-patterns.html) -- [Error Handling and Exception Reference](error-handling.html) - - ---- - -Source: resource-class-hierarchy.md - -# Understanding the Resource Class Hierarchy - -Every Kubernetes and OpenShift object you interact with through openshift-python-wrapper — whether it's a Pod, a Namespace, or a VirtualMachine — is a Python class that inherits from a small set of base classes. Understanding how these classes relate to each other helps you know **what capabilities any resource has**, **why some resources require a `namespace` argument and others don't**, and **how new resource types get added to the library**. - -This page explains the architecture so you can use existing resources effectively and extend the library when you need to work with a new custom resource. - ---- - -## The Big Picture - -The class hierarchy has three conceptual layers: - -| Layer | Classes | Purpose | -|---|---|---| -| **Constants** | `ResourceConstants` | Provides shared status strings, condition names, and type constants used across all resources | -| **Base classes** | `Resource`, `NamespacedResource` | Define all CRUD operations, lifecycle management, waiting, validation, and API discovery logic | -| **Concrete resource classes** | `Pod`, `Namespace`, `Deployment`, `VirtualMachine`, and 200+ others | Represent specific Kubernetes/OpenShift resource kinds, adding kind-specific parameters and behavior | - -The inheritance flows like this: - -``` - ResourceConstants - └── Resource ← cluster-scoped resources (Node, Namespace, StorageClass, ClusterRole, …) - └── NamespacedResource ← namespace-scoped resources (Pod, Deployment, ConfigMap, Secret, Route, …) -``` - -Every concrete resource class inherits from either `Resource` (for cluster-scoped resources) or `NamespacedResource` (for namespace-scoped resources). This single design decision controls whether the class requires a `namespace` argument and how it builds API requests. - ---- - -## Key Concepts - -### ResourceConstants: Shared Vocabulary - -At the root of the hierarchy sits `ResourceConstants`, which defines inner classes for common values: - -- **`Status`** — Strings like `RUNNING`, `SUCCEEDED`, `FAILED`, `PENDING`, `ACTIVE` -- **`Condition`** — Condition types like `READY`, `AVAILABLE`, `DEGRADED` and their status values (`TRUE`, `FALSE`, `UNKNOWN`) -- **`Type`** — Service types like `ClusterIP`, `NodePort`, `LoadBalancer` - -Because `Resource` inherits from `ResourceConstants`, every resource class in the library can reference these constants: - -```python - from ocp_resources.namespace import Namespace - - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) -``` - -Concrete classes can extend these constants with kind-specific values. For example, `VirtualMachine` adds statuses like `MIGRATING`, `STOPPED`, and `PROVISIONING` to the base `Status` class. - -### Resource: The Foundation for Cluster-Scoped Resources - -`Resource` is the main base class. It provides everything needed to manage a Kubernetes resource: - -| Capability | Methods / Properties | -|---|---| -| **CRUD operations** | `create()`, `delete()`, `update()`, `update_replace()` | -| **Lifecycle management** | `deploy()`, `clean_up()`, context manager (`with` statement) | -| **Querying** | `exists`, `instance`, `status`, `labels` | -| **Waiting** | `wait()`, `wait_deleted()`, `wait_for_status()`, `wait_for_condition()` | -| **Listing** | `get()` class method — yields resource objects matching filters | -| **Validation** | `validate()`, `validate_dict()` | -| **Serialization** | `to_dict()`, `to_yaml()` | -| **API discovery** | Automatic `api_version` resolution from the cluster when only `api_group` is set | - -Resources that exist at the cluster level — not inside any namespace — inherit directly from `Resource`. Examples include `Namespace`, `Node`, `StorageClass`, `ClusterRole`, and `CustomResourceDefinition`. - -```python - from ocp_resources.namespace import Namespace - - # No namespace argument needed — Namespace is cluster-scoped - ns = Namespace(client=client, name="my-namespace") -``` - -#### Automatic Kind Detection - -You never set the `kind` field manually. The `kind` property is a class-level property that automatically derives the Kubernetes kind name from the **class name** using Python's method resolution order (MRO). When you define a class named `StorageClass`, its `kind` is automatically `"StorageClass"`. - -#### API Version Discovery - -Resources can specify their API identity in two ways: - -1. **`api_version`** — A fixed version string (e.g., `"v1"`), used for core Kubernetes resources -2. **`api_group`** — An API group string (e.g., `"apps"`, `"kubevirt.io"`), where the full `apiVersion` is discovered dynamically from the cluster - -When only `api_group` is set, the library queries the cluster at runtime to find the latest supported version for that resource kind. This means resource classes automatically work across cluster versions without code changes. - -```python - class Namespace(Resource): - # Core resource — fixed version, no group - api_version: str = Resource.ApiVersion.V1 - - - class ClusterRole(Resource): - # Grouped resource — version discovered from cluster - api_group = Resource.ApiGroup.RBAC_AUTHORIZATION_K8S_IO -``` - -> **Note:** The `Resource.ApiGroup` and `Resource.ApiVersion` inner classes provide predefined constants for all known API groups and versions. Using these constants avoids typos and makes your code self-documenting. - -### NamespacedResource: Adding Namespace Awareness - -`NamespacedResource` extends `Resource` with one critical addition: **namespace handling**. It requires a `namespace` argument during construction and injects the namespace into all API calls. - -The differences from `Resource` are focused but important: - -| Behavior | `Resource` | `NamespacedResource` | -|---|---|---| -| `namespace` required? | No | Yes (unless using `yaml_file` or `kind_dict`) | -| `to_dict()` output | No namespace in metadata | Adds `metadata.namespace` | -| `get()` yields | Objects with `name` only | Objects with both `name` and `namespace` | -| `instance` property | Fetches by name only | Fetches by name and namespace | - -```python - from ocp_resources.pod import Pod - - # Namespace is required for namespaced resources - pod = Pod(client=client, name="my-pod", namespace="default", containers=[{"name": "app", "image": "nginx"}]) -``` - -### Concrete Resource Classes: Where Specifics Live - -Concrete classes add three things on top of the base classes: - -1. **API identity** — Setting `api_group` or `api_version` to identify which Kubernetes API to call -2. **Constructor parameters** — Typed arguments for the resource's spec fields (like `containers` for Pod, `replicas` for Deployment) -3. **Custom `to_dict()` method** — Builds the Kubernetes resource dictionary from constructor arguments - -Here is how a typical generated class is structured: - -```python - class Deployment(NamespacedResource): - # 1. API identity - api_group: str = NamespacedResource.ApiGroup.APPS - - def __init__(self, replicas=None, selector=None, template=None, **kwargs): - # 2. Pass common args to base class, store kind-specific args - super().__init__(**kwargs) - self.replicas = replicas - self.selector = selector - self.template = template - - def to_dict(self): - # 3. Build the resource dictionary - super().to_dict() - if not self.kind_dict and not self.yaml_file: - self.res["spec"] = {} - _spec = self.res["spec"] - _spec["selector"] = self.selector - _spec["template"] = self.template - if self.replicas is not None: - _spec["replicas"] = self.replicas -``` - -> **Tip:** You can always bypass the typed constructor entirely by passing `yaml_file` or `kind_dict` to any resource class. When you do, the `to_dict()` logic is skipped and the resource is created from your raw definition instead. - -#### Adding Kind-Specific Behavior - -Many concrete classes go beyond what the generator produces by adding custom methods and properties. For example: - -- **Pod** adds `execute()` for running commands, `log()` for reading logs, and a `node` property -- **Deployment** adds `scale_replicas()` and `wait_for_replicas()` -- **Secret** overrides `keys_to_hash` to ensure sensitive data is masked in logs - -These additions are preserved across regeneration because the class generator recognizes the `# End of generated code` marker and keeps any code written below it. - -### How Generated Classes Are Created - -Most concrete resource classes in the library are **code-generated** from the cluster's OpenAPI schema using the `class-generator` tool. The generator: - -1. Reads the resource definition from the cluster's OpenAPI schema -2. Determines whether the resource is namespaced (→ `NamespacedResource`) or cluster-scoped (→ `Resource`) -3. Extracts spec fields with their types and descriptions -4. Renders a Python class from a Jinja2 template -5. Preserves any hand-written code below the `# End of generated code` marker - -This means the hierarchy is not just an architectural choice — it is **enforced by the code generation pipeline**. Every generated class correctly inherits from the appropriate base class based on the resource's actual scope in Kubernetes. - -See [Generating New Resource Classes with class-generator](generating-resource-classes.html) for details on generating classes for new resource types. - ---- - -## How It Affects You - -Understanding the hierarchy helps you in several practical ways: - -### Knowing What Methods Are Available - -Every resource — regardless of kind — inherits the full set of CRUD, waiting, and lifecycle methods from `Resource`. You don't need to check whether a particular resource supports `wait_for_condition()` or context managers; they all do. - -```python - # Works for any resource type - with SomeResource(client=client, name="example", **specific_args) as res: - res.wait_for_condition(condition="Ready", status="True") -``` - -See [Resource and NamespacedResource API](resource-api.html) for the complete method reference. - -### Understanding Constructor Requirements - -The base class determines what arguments are mandatory: - -| If you're using... | Required arguments | -|---|---| -| `Resource` subclass | `client`, `name` | -| `NamespacedResource` subclass | `client`, `name`, `namespace` | -| Any class with `yaml_file` | `client`, `yaml_file` | -| Any class with `kind_dict` | `client`, `kind_dict` | - -See [Creating and Managing Resources](creating-and-managing-resources.html) for full examples of all creation methods. - -### Using Status and Condition Constants - -The `Status` and `Condition` constants inherited from `ResourceConstants` are available on every resource class. Concrete classes may extend them: - -```python - from ocp_resources.virtual_machine import VirtualMachine - - # Base constants work on all resources - vm.wait_for_status(status=VirtualMachine.Status.RUNNING) - - # Kind-specific constants are added by the concrete class - vm.wait_for_status(status=VirtualMachine.Status.STOPPED) -``` - -### Extending the Library - -If you need to work with a CRD that isn't included in the library, you have two options: - -1. **Use the class generator** to scaffold a new class automatically — see [Generating New Resource Classes with class-generator](generating-resource-classes.html) -2. **Write a class manually** by inheriting from `Resource` or `NamespacedResource` and setting `api_group` or `api_version` - -A minimal hand-written resource class looks like this: - -```python - from ocp_resources.resource import NamespacedResource - - - class MyCustomResource(NamespacedResource): - api_group = "example.com" - # That's it — you get full CRUD, waiting, and lifecycle support -``` - -> **Warning:** Your class name must match the Kubernetes `kind` exactly (in PascalCase). The `kind` property is derived from the class name automatically — a class named `MyCustomResource` will have `kind = "MyCustomResource"`. - ---- - -## Related Pages - -- [Resource and NamespacedResource API](resource-api.html) — Complete method and property reference for the base classes -- [Creating and Managing Resources](creating-and-managing-resources.html) — Practical guide to creating, updating, and deleting resources -- [Generating New Resource Classes with class-generator](generating-resource-classes.html) — Generate Python classes for any CRD -- [Querying and Watching Resources](querying-resources.html) — Use the `get()` class method and watchers to list and observe resources -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) — Use the waiting methods inherited from the base classes -- [Validating Resources Against OpenAPI Schemas](validating-resources.html) — Validate resources using the built-in schema validation -- [Environment Variables and Configuration](environment-variables.html) — Configure logging, resource reuse, and teardown behavior - -## Related Pages - -- [Resource and NamespacedResource API](resource-api.html) -- [Generating New Resource Classes with class-generator](generating-resource-classes.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Schema Validation and Code Generation Architecture](schema-validation-internals.html) -- [Validating Resources Against OpenAPI Schemas](validating-resources.html) - - ---- - -Source: schema-validation-internals.md - -# Schema Validation and Code Generation Architecture - -The openshift-python-wrapper uses OpenAPI schemas as the single source of truth for two critical capabilities: **validating resource definitions** before they reach the cluster, and **generating Python wrapper classes** for any Kubernetes or OpenShift resource type. Understanding this architecture helps you diagnose validation errors, generate classes for custom resources, and keep your schemas up to date as clusters evolve. - -## The Big Picture - -OpenAPI schemas flow through the system in two directions: they are fetched from a live cluster and stored locally, then consumed by the validation engine at runtime and by the code generator at development time. - -| Component | Location | Purpose | -|---|---|---| -| **Schema Fetcher** | `class_generator/core/schema.py` | Connects to a cluster, downloads OpenAPI v3 schemas, and writes them to disk | -| **Resource Mappings** | `class_generator/schema/__resources-mappings.json.gz` | Compressed archive mapping each resource kind (lowercase) to its schema(s) | -| **Definitions File** | `class_generator/schema/_definitions.json` | JSON file containing detailed schema definitions with `$ref` targets for nested types | -| **Schema Validator** | `ocp_resources/utils/schema_validator.py` | Runtime engine that loads schemas, resolves `$ref` references, and validates resource dicts | -| **Code Generator** | `class_generator/core/generator.py` | Reads schemas and generates Python class files from a Jinja2 template | -| **Explain Parser** | `class_generator/parsers/explain_parser.py` | Parses the resource mapping to extract fields, types, and group-version-kind metadata | -| **Cluster Version Tracker** | `class_generator/schema/__cluster_version__.txt` | Tracks the cluster version that last produced the schema, controlling update strategy | - -### Data Flow: Schema Fetch and Storage - -1. **Detect client binary** — The system finds `oc` or falls back to `kubectl` via `get_client_binary()`. -2. **Check cluster version** — `check_and_update_cluster_version()` compares the connected cluster's version against the stored version in `__cluster_version__.txt`. -3. **Determine update strategy** — If the cluster is the same version or newer, all schemas are fetched and existing entries are updated. If older, only missing resources are fetched and existing schemas are preserved. -4. **Fetch OpenAPI v3 index** — `GET /openapi/v3` returns an index of all API group paths (e.g., `api/v1`, `apis/apps/v1`). -5. **Fetch schemas in parallel** — `fetch_all_api_schemas()` downloads schemas from each API path using a thread pool (up to 10 workers). -6. **Build namespacing dictionary** — `build_namespacing_dict()` queries `api-resources` to determine which kinds are namespaced vs. cluster-scoped. -7. **Process definitions** — `process_schema_definitions()` extracts `x-kubernetes-group-version-kind` metadata, builds schema entries, and merges them into the resource mappings. Existing resources are **never deleted** — only added or updated. -8. **Supplement with `oc explain`** — Missing field descriptions, required-field markers, and `$ref` definitions are filled in by running `oc explain` commands in parallel. -9. **Write to disk** — The resource mappings are saved as a gzip-compressed JSON archive (`__resources-mappings.json.gz`), and definitions are written to `_definitions.json`. - -### Data Flow: Validation at Runtime - -1. **Load on first use** — `SchemaValidator.load_mappings_data()` decompresses and loads the mappings archive and definitions file into class-level caches. -2. **Look up by kind** — `SchemaValidator.load_schema(kind, api_group)` finds the schema for a resource kind (case-insensitive). When multiple API groups define the same kind (e.g., `Ingress` in `networking.k8s.io` and `config.openshift.io`), the `api_group` parameter disambiguates. -3. **Resolve `$ref` references** — The validator recursively resolves all `$ref` pointers against the definitions data, producing a self-contained schema. -4. **Cache resolved schemas** — Resolved schemas are stored in `_schema_cache` keyed by `api_group:kind`, so subsequent validations are fast. -5. **Validate with jsonschema** — `jsonschema.validate()` checks the resource dictionary against the resolved schema. -6. **Format errors** — If validation fails, `format_validation_error()` produces a human-readable message including the field path, error details, and schema context. - -### Data Flow: Code Generation - -1. **Read resource mappings** — `parse_explain()` loads the mapping file and finds all schema entries for the requested kind. -2. **Select latest API version** — When multiple versions exist within an API group, the latest version is selected using a priority ranking (`v2 > v1 > v1beta2 > v1beta1 > v1alpha2 > v1alpha1`). -3. **Extract fields and types** — The `type_parser` module converts OpenAPI types to Python type annotations and builds parameter dictionaries for the class constructor. -4. **Render Jinja2 template** — `render_jinja_template()` processes `class_generator_template.j2` with the extracted data, producing a complete Python class. -5. **Preserve user code** — If the target file already exists, `parse_user_code_from_file()` extracts any user-added code below the `# End of generated code` marker and re-inserts it after regeneration. -6. **Format and write** — `write_and_format_rendered()` writes the file, then formats it with `prek` (the repository's own pre-commit hooks, unmodified). prek runs a second time to reach a fixpoint; if it still fails, the kind fails loudly instead of leaving an unformatted file. No other formatter is invoked, so a file that passes locally also passes CI. - -## Key Concepts - -### The Resource Mappings File - -The resource mappings file (`__resources-mappings.json.gz`) is the central schema store. It maps lowercase kind names to arrays of schema objects: - -```json - { - "pod": [ - { - "description": "Pod is a collection of containers...", - "properties": { ... }, - "required": [], - "type": "object", - "x-kubernetes-group-version-kind": [ - { "group": "", "kind": "Pod", "version": "v1" } - ], - "namespaced": true - } - ], - "ingress": [ - { "x-kubernetes-group-version-kind": [{ "group": "networking.k8s.io", ... }], ... }, - { "x-kubernetes-group-version-kind": [{ "group": "config.openshift.io", ... }], ... } - ] - } -``` - -Resources with the same kind but different API groups (like `Ingress` above) are stored as separate entries in the array. This structure allows the validator and generator to handle API group disambiguation correctly. - -> **Note:** The mappings file is compressed with gzip to reduce repository size. The `archive_utils` module handles transparent compression and decompression via `save_json_archive()` and `load_json_archive()`. - -### The Definitions File - -The definitions file (`_definitions.json`) contains detailed schema definitions keyed by `group/version/Kind` paths (e.g., `apps/v1/Deployment`, `v1/Pod`). It also stores referenced sub-schemas like `io.k8s.api.core.v1.PodSpec` that are targets of `$ref` pointers. During validation, the `SchemaValidator` uses this file as the reference store for resolving nested types. - -### SchemaValidator - -`SchemaValidator` is a class with only class-level methods and caches — you never need to instantiate it. It serves as the shared validation engine used by both `Resource.validate()` and `Resource.validate_dict()`. - -```python - from ocp_resources.utils.schema_validator import SchemaValidator -``` - -| Method | Signature | Description | -|---|---|---| -| `load_mappings_data` | `(skip_cache: bool = False) -> bool` | Loads the mappings archive and definitions file. Returns `True` on success. | -| `get_mappings_data` | `(skip_cache: bool = False) -> dict[str, Any] \| None` | Returns loaded mappings data, loading first if needed. | -| `get_definitions_data` | `() -> dict[str, Any] \| None` | Returns loaded definitions data, loading first if needed. | -| `load_schema` | `(kind: str, api_group: str \| None = None) -> dict[str, Any] \| None` | Loads and resolves a complete schema for a kind. Handles API group disambiguation and caching. | -| `validate` | `(resource_dict: dict[str, Any], kind: str, api_group: str \| None = None) -> None` | Validates a resource dict. Raises `jsonschema.ValidationError` on failure. | -| `format_validation_error` | `(error, kind, name, api_group=None) -> str` | Formats a validation error into a user-friendly message with field path and context. | -| `clear_cache` | `() -> None` | Clears the resolved schema cache (not the raw mappings/definitions data). | - -> **Tip:** The `load_schema` method resolves `$ref` references recursively. For core Kubernetes types like `ObjectMeta` or `TypeMeta` that may be missing from definitions, it falls back to a permissive `{"type": "object", "additionalProperties": true}` schema rather than failing outright. - -### Resource Validation Methods - -The `Resource` base class exposes two validation methods that delegate to `SchemaValidator`: - -```python - from ocp_resources.resource import Resource - - # Instance-level validation — validates self.res - resource.validate() - - # Class-level validation — validates any dict against the class's schema - MyResource.validate_dict(resource_dict) -``` - -Both raise `ocp_resources.exceptions.ValidationError` (not the raw `jsonschema.ValidationError`) with formatted error messages that include the resource identifier, field path, and details. - -**Auto-validation** can be enabled per-instance by passing `schema_validation_enabled=True` to the constructor: - -```python - from ocp_resources.pod import Pod - - pod = Pod( - name="my-pod", - namespace="default", - containers=[{"name": "app", "image": "nginx"}], - client=client, - schema_validation_enabled=True, - ) - # validation runs automatically before create() and update_replace() - pod.deploy() -``` - -When auto-validation is enabled: -- `create()` calls `self.validate()` after `to_dict()` builds the resource dictionary -- `update_replace()` calls `validate_dict()` on the replacement resource dictionary -- `update()` (patch operations) does **not** trigger validation because patches are partial documents that may not pass full schema validation - -### Schema Update Strategies - -The `update_kind_schema()` function employs a version-aware update strategy: - -| Cluster Version | Behavior | -|---|---| -| **Same or newer** than last recorded | Fetches **all** API schemas, updates existing resources, supplements with `oc explain` data | -| **Older** than last recorded | Identifies only **missing** resources via `identify_missing_resources()`, fetches only relevant API paths, does **not** modify existing schemas | - -This strategy prevents an older cluster from overwriting schemas that contain richer data from a newer cluster. - -> **Warning:** If you connect to an older cluster and need to update a specific CRD's schema (for example, after installing a new operator), use `update_single_resource_schema(kind)` or the CLI flag `--update-schema-for `. This bypasses the version guard for that one resource. - -### Single-Resource Schema Updates - -The `update_single_resource_schema()` function fetches the schema for exactly one resource kind without affecting other resources in the mapping: - -```python - from class_generator.core.schema import update_single_resource_schema - - update_single_resource_schema(kind="LlamaStackDistribution") -``` - -| Parameter | Type | Description | -|---|---|---| -| `kind` | `str` | The resource Kind (case-sensitive, e.g., `"LlamaStackDistribution"`) | -| `client` | `str \| None` | Client binary path. Auto-detected if `None`. | - -**Raises:** -- `ResourceNotFoundError` — if the kind is not found on the cluster -- `RuntimeError` — if schema fetching fails - -### Code Generation Pipeline - -The `class_generator()` function orchestrates the full generation pipeline: - -```python - from class_generator.core.generator import class_generator - - generated_files = class_generator(kind="Deployment", overwrite=True) -``` - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `kind` | `str` | (required) | Kubernetes resource kind | -| `overwrite` | `bool` | `False` | Overwrite existing files | -| `dry_run` | `bool` | `False` | Print output without writing | -| `output_file` | `str` | `""` | Specific output file path | -| `output_dir` | `str` | `""` | Output directory (defaults to `ocp_resources`) | -| `add_tests` | `bool` | `False` | Generate test files in `class_generator/tests/manifests/` | - -**Returns:** `list[str]` — paths of generated files. - -**Raises:** -- `RuntimeError` — if the kind is not found in schema mappings -- `ValueError` — if the generated filename contains invalid patterns - -When a kind exists in multiple API groups (e.g., `Ingress` in both `networking.k8s.io` and `config.openshift.io`), the generator produces **separate files** with a group-based suffix (e.g., `ingress_networking_k8s_io.py`, `ingress_config_openshift_io.py`). - -### Generated Class Structure - -Every generated file follows a consistent structure produced by the Jinja2 template: - -```python - # Generated using https://github.com/RedHatQE/openshift-python-wrapper/blob/main/class_generator/README.md - - from typing import Any - from ocp_resources.resource import NamespacedResource - - class Pod(NamespacedResource): - """Pod is a collection of containers that can run on a host.""" - - api_version: str = NamespacedResource.ApiVersion.V1 - - def __init__(self, containers: list[Any] | None = None, ..., **kwargs: Any) -> None: - """Args: - containers (list[Any]): List of containers belonging to the pod. - ... - """ - super().__init__(**kwargs) - self.containers = containers - ... - - def to_dict(self) -> None: - super().to_dict() - if not self.kind_dict and not self.yaml_file: - # Required fields raise MissingRequiredArgumentError - # Optional fields added only if not None - ... - # End of generated code -``` - -User-added code placed **after** the `# End of generated code` marker is preserved across regenerations. - -### `$ref` Resolution - -OpenAPI schemas extensively use `$ref` pointers to reference shared type definitions (e.g., `"$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"`). The `SchemaValidator._resolve_refs()` method handles this by: - -1. Parsing the reference path to extract the definition name -2. Looking up the definition in `_definitions_data` using multiple key formats (full dotted path, short name, or `version/Kind` format) -3. Recursively resolving any `$ref` pointers within the resolved definition -4. Falling back to a permissive object schema for well-known core types that may be missing - -### Schema Supplementation via `oc explain` - -The raw OpenAPI v3 schemas from the cluster sometimes lack field descriptions and required-field markers. The system supplements these gaps by: - -- Running `oc explain .` commands **in parallel** for critical resource types (Pod, Deployment, Service, etc.) -- Parsing the explain output to extract field descriptions, types, and required-field markers -- Merging descriptions into existing schema properties where they are missing -- Detecting missing `$ref` targets and fetching their definitions via `oc explain --recursive` - -This supplementation only runs during a **full update** (same or newer cluster version), not when only fetching missing resources. - -## How It Affects You - -| What you do | What happens under the hood | -|---|---| -| Call `resource.validate()` | `SchemaValidator` loads schemas from the compressed archive, resolves `$ref` references, validates with jsonschema, and raises `ValidationError` with a formatted message | -| Pass `schema_validation_enabled=True` | `create()` and `update_replace()` automatically validate before sending to the API server | -| Run `class-generator --update-schema` | Full schema fetch from cluster, version check, parallel API downloads, `oc explain` supplementation, and compressed archive write | -| Run `class-generator -k Pod` | Reads the mapping for `pod`, parses fields/types, renders Jinja2 template, preserves user code, writes and formats the Python file | -| Run `class-generator --update-schema-for MyResource` | Single-resource fetch that bypasses the cluster version guard | -| Run `class-generator --regenerate-all` | Discovers all generated files (by marker comment), regenerates each in parallel, preserving user code | -| Validation reports "No schema found" | The resource kind is not in the mappings archive — run `--update-schema` to fetch it | -| Multiple files generated for one kind | The kind exists in multiple API groups — each group gets its own file with a suffix | - -## Related Pages - -- See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for a hands-on guide to enabling and using validation. -- See [Generating New Resource Classes with class-generator](generating-resource-classes.html) for step-by-step class generation instructions. -- See [class-generator CLI Reference](class-generator-cli.html) for all CLI options including `--update-schema`, `--update-schema-for`, `--regenerate-all`, and `--coverage-report`. -- See [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) to learn how generated classes fit into the `Resource` / `NamespacedResource` inheritance tree. -- See [Resource and NamespacedResource API](resource-api.html) for complete documentation of the `validate()` and `validate_dict()` methods. -- See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for how the fake client uses schema-based status templates. - -## Related Pages - -- [Validating Resources Against OpenAPI Schemas](validating-resources.html) -- [Generating New Resource Classes with class-generator](generating-resource-classes.html) -- [class-generator CLI Reference](class-generator-cli.html) -- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) -- [Resource and NamespacedResource API](resource-api.html) - - ---- - -Source: working-with-events.md - -# Working with Kubernetes Events - -Monitor, retrieve, and clean up Kubernetes events to debug resource behavior and automate event-driven workflows using the `Event` class and the `resource.events()` method. - -## Prerequisites - -- A connected Kubernetes/OpenShift client — see [Connecting to Clusters](connecting-to-clusters.html) -- The `ocp_resources` package installed — see [Installing and Creating Your First Resource](quickstart.html) - -## Quick Example - -Stream events from a namespace in real-time: - -```python - from ocp_resources.event import Event - - for event in Event.get(client=client, namespace="my-namespace", timeout=30): - print(f"{event['type']}: {event.object.reason} — {event.object.message}") -``` - -## Streaming Events in Real-Time with `Event.get()` - -`Event.get()` opens a watch connection and yields events as they occur. It's a generator that blocks until the timeout expires or you break out of the loop. - -```python - from ocp_resources.event import Event - - for event in Event.get( - client=client, - namespace="my-namespace", - timeout=60, - ): - obj = event.object - print(f"[{obj.type}] {obj.reason}: {obj.message}") -``` - -Each yielded event is a watch event dictionary with these keys: - -| Key | Description | -|----------------|--------------------------------------------------------------------| -| `type` | Watch event type: `ADDED`, `MODIFIED`, or `DELETED` | -| `object` | The Event resource object (access `.type`, `.reason`, `.message`, `.involvedObject`, etc.) | -| `raw_object` | The raw dictionary representation of the event | - -### `Event.get()` Parameters - -| Parameter | Type | Default | Description | -|--------------------|-------------------------|----------|-----------------------------------------------------------| -| `client` | `DynamicClient` | `None` | Kubernetes dynamic client (required) | -| `namespace` | `str \| None` | `None` | Filter events to a specific namespace | -| `name` | `str \| None` | `None` | Filter by event name | -| `label_selector` | `str \| None` | `None` | Filter by labels (e.g. `"app=nginx"`) | -| `field_selector` | `str \| None` | `None` | Filter by fields (e.g. `"type==Warning"`) | -| `resource_version` | `str \| None` | `None` | Start watching from a specific resource version | -| `timeout` | `int \| None` | `None` | Timeout in seconds; `None` watches indefinitely | - -**Returns:** `Generator` — yields watch event dictionaries. - -### Filtering with Field Selectors - -Field selectors let you narrow down events to exactly what you care about. Combine multiple selectors with commas: - -```python - # Only Warning events for ClusterServiceVersion resources - for event in Event.get( - client=client, - namespace="my-namespace", - field_selector="involvedObject.kind==ClusterServiceVersion,type==Warning,reason==AnEventReason", - timeout=10, - ): - print(event.object.message) -``` - -Common field selector fields: - -| Field | Example | -|------------------------------|-------------------------------------------------| -| `type` | `type==Warning` or `type==Normal` | -| `reason` | `reason==FailedScheduling` | -| `involvedObject.kind` | `involvedObject.kind==Pod` | -| `involvedObject.name` | `involvedObject.name==my-pod` | -| `involvedObject.namespace` | `involvedObject.namespace==default` | - -## Listing Existing Events with `Event.list()` - -Unlike `Event.get()`, which streams events in real-time, `Event.list()` returns existing events immediately as a list. By default it only returns events from the last 5 minutes. - -```python - from ocp_resources.event import Event - - # List all Warning events from the last 5 minutes - events = Event.list( - client=client, - namespace="my-namespace", - field_selector="type==Warning", - ) - - for event in events: - print(f"{event.reason}: {event.message}") -``` - -Results are sorted by `lastTimestamp` descending (most recent first). - -### `Event.list()` Parameters - -| Parameter | Type | Default | Description | -|------------------|---------------------|---------|-----------------------------------------------------------| -| `client` | `DynamicClient` | — | Kubernetes dynamic client (required) | -| `namespace` | `str \| None` | `None` | Filter events to a specific namespace | -| `field_selector` | `str \| None` | `None` | Filter by fields (e.g. `"type==Warning"`) | -| `label_selector` | `str \| None` | `None` | Filter by labels | -| `since_seconds` | `int` | `300` | Only return events from the last N seconds | - -**Returns:** `list` — event resource objects sorted by timestamp (most recent first). - -**Raises:** `ValueError` — if `since_seconds` is negative. - -```python - # Events from the last 30 minutes - events = Event.list(client=client, since_seconds=1800) - - # All events (no time filter — uses a very large window) - events = Event.list(client=client, since_seconds=999999) -``` - -### When to Use `Event.get()` vs `Event.list()` - -| Use case | Method | -|--------------------------------------------------|----------------| -| Watch for new events as they happen | `Event.get()` | -| Fetch events that already occurred | `Event.list()` | -| Collect events during a test run | `Event.get()` | -| Check recent events after a failure | `Event.list()` | -| Stream events with a timeout | `Event.get()` | -| Get a snapshot sorted by time | `Event.list()` | - -## Getting Events for a Specific Resource - -Every resource instance has an `.events()` method that automatically filters events to that specific resource by setting `involvedObject.name` in the field selector. - -```python - from ocp_resources.pod import Pod - - pod = Pod(client=client, name="my-pod", namespace="default") - - for event in pod.events(timeout=10): - print(f"{event.object.reason}: {event.object.message}") -``` - -You can add extra filters on top of the automatic resource filter: - -```python - # Only Warning events for this specific pod - for event in pod.events( - field_selector="type==Warning", - timeout=10, - ): - print(event.object.message) -``` - -### `resource.events()` Parameters - -| Parameter | Type | Default | Description | -|--------------------|---------|---------|-----------------------------------------------------------| -| `name` | `str` | `""` | Filter by event name | -| `label_selector` | `str` | `""` | Filter by labels | -| `field_selector` | `str` | `""` | Additional field selectors (combined with `involvedObject.name` automatically) | -| `resource_version` | `str` | `""` | Start watching from a specific resource version | -| `timeout` | `int` | `240` | Timeout in seconds (default: 4 minutes) | - -**Returns:** `Generator` — yields watch event dictionaries, same format as `Event.get()`. - -> **Note:** The `field_selector` you provide is appended to the automatic `involvedObject.name==` filter. You don't need to specify the resource name yourself. - -## Deleting Events with `Event.delete_events()` - -Clean up events before a test run to avoid false positives from stale events: - -```python - from ocp_resources.event import Event - - # Delete all events in a namespace - Event.delete_events(client=client, namespace="my-namespace") - - # Delete events matching a specific reason - Event.delete_events( - client=client, - namespace="my-namespace", - field_selector="reason==AnEventReason", - ) -``` - -### `Event.delete_events()` Parameters - -| Parameter | Type | Default | Description | -|--------------------|-------------------------|----------|-----------------------------------------------------------| -| `client` | `DynamicClient` | `None` | Kubernetes dynamic client (required) | -| `namespace` | `str \| None` | `None` | Target namespace | -| `name` | `str \| None` | `None` | Specific event name to delete | -| `label_selector` | `str \| None` | `None` | Filter by labels | -| `field_selector` | `str \| None` | `None` | Filter by fields | -| `resource_version` | `str \| None` | `None` | Filter by resource version | -| `timeout` | `int \| None` | `None` | Timeout in seconds | - -**Returns:** `None` - -## Advanced Usage - -### Test Setup: Clean Events Before Each Test - -A common pattern is deleting events before a test so that only events generated during the test are captured: - -```python - from ocp_resources.event import Event - - - def test_pod_scheduling(client, namespace): - # Clean slate — remove old events - Event.delete_events(client=client, namespace=namespace) - - # ... create resources and trigger the behavior under test ... - - # Verify expected events occurred - events = Event.list( - client=client, - namespace=namespace, - field_selector="reason==Scheduled", - since_seconds=60, - ) - assert len(events) > 0, "Pod was not scheduled" -``` - -### Collecting Events During an Operation - -Use `Event.get()` with a timeout to capture all events that occur during an operation: - -```python - from ocp_resources.event import Event - - events = [] - for event in Event.get( - client=client, - namespace="my-namespace", - field_selector="involvedObject.kind==Deployment", - timeout=30, - ): - events.append(event.object) - if event.object.reason == "ScalingReplicaSet": - break # Found what we were looking for - - print(f"Captured {len(events)} events") -``` - -### Watching Cluster-Wide Events - -Omit the `namespace` parameter to watch events across all namespaces: - -```python - from ocp_resources.event import Event - - # Watch all Warning events cluster-wide - for event in Event.get( - client=client, - field_selector="type==Warning", - timeout=60, - ): - ns = event.object.involvedObject.get("namespace", "cluster-scoped") - print(f"[{ns}] {event.object.reason}: {event.object.message}") -``` - -### Accessing Event Object Properties - -The `event.object` yielded by `Event.get()` and `resource.events()` is a Kubernetes `ResourceInstance` with these commonly used attributes: - -| Attribute | Description | -|-------------------------------|----------------------------------------------| -| `event.object.type` | `"Normal"` or `"Warning"` | -| `event.object.reason` | Short reason string (e.g. `"Scheduled"`) | -| `event.object.message` | Human-readable event message | -| `event.object.count` | Number of times this event occurred | -| `event.object.firstTimestamp` | When the event first occurred | -| `event.object.lastTimestamp` | When the event most recently occurred | -| `event.object.source` | Dict with `component` and `host` | -| `event.object.involvedObject` | Dict with `kind`, `name`, `namespace`, etc. | - -## Troubleshooting - -**`Event.get()` hangs indefinitely** -You likely omitted the `timeout` parameter. Always set a `timeout` value to avoid blocking forever: - -```python - # Bad — hangs if no events arrive - for event in Event.get(client=client, namespace="ns"): - ... - - # Good — stops after 30 seconds - for event in Event.get(client=client, namespace="ns", timeout=30): - ... -``` - -**`Event.list()` returns an empty list** -- Check the `since_seconds` value. The default is 300 (5 minutes). If the events are older, increase the value. -- Verify the `namespace` is correct. -- Ensure the `field_selector` syntax uses `==` (double equals), not `=`. - -**`resource.events()` returns events for other resources too** -This method filters only by `involvedObject.name`. If multiple resources share the same name across different kinds, add a `field_selector` to narrow it down: - -```python - for event in pod.events( - field_selector="involvedObject.kind==Pod", - timeout=10, - ): - print(event.object.message) -``` - -> **Warning:** The `dyn_client` parameter on `Event.get()` and `Event.delete_events()` is deprecated and will be removed in the next major release. Use `client` instead. - -## Related Pages - -- [Querying and Watching Resources](querying-resources.html) -- [Common Resource Patterns](common-patterns.html) -- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) - - ---- - -Source: pod-execution-and-logs.md - -# Executing Commands in Pods and Retrieving Logs - -Run commands inside running pods and retrieve their logs to debug and interact with workloads on your OpenShift or Kubernetes cluster. - -## Prerequisites - -- A connected cluster client (see [Connecting to Clusters](connecting-to-clusters.html)) -- An existing, running pod you want to interact with - -## Quick Example - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - pod = Pod(client=client, name="my-app", namespace="default") - - # Run a command inside the pod - output = pod.execute(command=["echo", "hello"]) - print(output) # "hello\n" - - # Get pod logs - logs = pod.log() - print(logs) -``` - -## Executing Commands with `Pod.execute()` - -`Pod.execute()` runs a command inside a pod container using the Kubernetes exec API and returns the standard output as a string. - -### Method Signature - -```python - Pod.execute( - command: list[str], - timeout: int = 60, - container: str = "", - ignore_rc: bool = False, - ) -> str -``` - -| Parameter | Type | Default | Description | -|-------------|--------------|---------|---------------------------------------------------------------------------------------------| -| `command` | `list[str]` | — | The command to run, as a list of strings (e.g., `["ls", "-la", "/tmp"]`) | -| `timeout` | `int` | `60` | Maximum seconds to wait for the command to complete | -| `container` | `str` | `""` | Container name to execute in. If empty, uses the first container in the pod spec | -| `ignore_rc` | `bool` | `False` | When `True`, return stdout even if the command exits with a non-zero return code | - -**Returns:** `str` — the standard output of the command. - -**Raises:** `ExecOnPodError` — when the command fails (non-zero exit code) and `ignore_rc` is `False`. - -### Step-by-Step: Running a Simple Command - -1. Get a reference to the pod: - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - pod = Pod(client=client, name="my-app", namespace="my-namespace") -``` - -2. Execute a command: - -```python - output = pod.execute(command=["cat", "/etc/hostname"]) - print(output) -``` - -3. Use the result for further logic: - -```python - files = pod.execute(command=["ls", "/app/data"]) - for filename in files.strip().split("\n"): - print(f"Found: {filename}") -``` - -### Selecting a Container - -For multi-container pods, specify which container to run the command in. If you omit `container`, the first container defined in the pod spec is used. - -```python - # Execute in a specific container - output = pod.execute( - command=["cat", "/var/log/app.log"], - container="sidecar", - ) -``` - -### Setting a Timeout - -Long-running commands may need a longer timeout. The default is 60 seconds. - -```python - # Allow up to 5 minutes for a heavy operation - output = pod.execute( - command=["pg_dump", "mydb"], - timeout=300, - ) -``` - -When the timeout is exceeded, an `ExecOnPodError` is raised with the error message `"stream resp is closed"`. - -### Ignoring Non-Zero Exit Codes - -Some commands return a non-zero exit code as part of normal operation (e.g., `grep` returns 1 when no match is found). Use `ignore_rc=True` to get the output regardless: - -```python - # grep returns exit code 1 when there's no match — don't treat that as an error - output = pod.execute( - command=["grep", "ERROR", "/var/log/app.log"], - ignore_rc=True, - ) - if output: - print("Errors found:", output) - else: - print("No errors in logs") -``` - -### Handling Errors with `ExecOnPodError` - -When a command fails, `ExecOnPodError` provides structured access to the failure details. - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ExecOnPodError - - try: - pod.execute(command=["ls", "/nonexistent"]) - except ExecOnPodError as e: - print(f"Command: {e.cmd}") # ['ls', '/nonexistent'] - print(f"Return code: {e.rc}") # Non-zero exit code (or -1 for stream errors) - print(f"Stdout: {e.out}") # Standard output captured before failure - print(f"Stderr: {e.err}") # Standard error or error channel details -``` - -`ExecOnPodError` attributes: - -| Attribute | Type | Description | -|-----------|-------------|----------------------------------------------------------------------| -| `cmd` | `list[str]` | The command that was executed | -| `rc` | `int` | Return code (`-1` for stream/timeout errors, otherwise the exit code)| -| `out` | `str` | Standard output captured from the command | -| `err` | `str` or `dict` | Standard error output, or the Kubernetes error channel response | - -> **Tip:** For a complete list of all custom exceptions, see [Error Handling and Exception Reference](error-handling.html). - -## Retrieving Logs with `Pod.log()` - -`Pod.log()` returns the logs from a pod container as a string. It passes keyword arguments directly to the Kubernetes `read_namespaced_pod_log` API. - -### Basic Usage - -```python - logs = pod.log() - print(logs) -``` - -### Common Keyword Arguments - -Pass any parameter supported by the Kubernetes `read_namespaced_pod_log` API: - -| Parameter | Type | Description | -|-----------------|--------|-----------------------------------------------------------| -| `container` | `str` | Container name to get logs from (required for multi-container pods) | -| `previous` | `bool` | Return logs from a previous terminated container instance | -| `tail_lines` | `int` | Number of lines from the end of the logs to return | -| `since_seconds` | `int` | Only return logs newer than this many seconds | - -### Examples - -```python - # Get logs from a specific container - logs = pod.log(container="nginx") - - # Get the last 50 lines - logs = pod.log(tail_lines=50) - - # Get logs from the last 5 minutes - logs = pod.log(since_seconds=300) - - # Get logs from a crashed container's previous instance - logs = pod.log(container="app", previous=True) -``` - -## Accessing Pod Properties - -Beyond executing commands and reading logs, the `Pod` class provides properties for inspecting the pod's runtime state. - -### Getting the Node - -The `node` property returns a `Node` object for the node where the pod is scheduled: - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - - for pod in Pod.get(client=client, label_selector="app=my-app"): - node = pod.node - print(f"Pod {pod.name} is running on node {node.name}") -``` - -> **Note:** The `node` property raises an `AssertionError` if the pod has not yet been scheduled to a node. - -### Getting the Pod IP Address - -The `ip` property returns the pod's IP address from its status: - -```python - pod_ip = pod.ip - print(f"Pod IP: {pod_ip}") -``` - -### Getting the Pod Status - -The `status` property (inherited from the base resource class) returns the pod's phase: - -```python - print(f"Pod status: {pod.status}") # e.g., "Running", "Pending", "Succeeded" -``` - -## Advanced Usage - -### Iterating Over Pods with Selectors - -Combine `Pod.get()` with `execute()` or `log()` to debug across multiple pods: - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ExecOnPodError - from ocp_resources.resource import get_client - - client = get_client() - - for pod in Pod.get(client=client, namespace="production", label_selector="app=web"): - try: - uptime = pod.execute(command=["uptime"]) - print(f"{pod.name} on {pod.node.name}: {uptime.strip()}") - except ExecOnPodError as e: - print(f"{pod.name}: command failed — {e}") -``` - -### Executing in Multi-Container Pods - -When pods contain sidecar containers (e.g., logging or proxy containers), always specify the target container explicitly: - -```python - # Main application container - app_output = pod.execute(command=["cat", "/app/config.yaml"], container="app") - - # Envoy sidecar - proxy_stats = pod.execute(command=["curl", "localhost:15000/stats"], container="istio-proxy") - - # Logs from each container - app_logs = pod.log(container="app", tail_lines=100) - proxy_logs = pod.log(container="istio-proxy", tail_lines=100) -``` - -### Collecting Debug Information - -A practical recipe for gathering debugging data from a running pod: - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ExecOnPodError - from ocp_resources.resource import get_client - - client = get_client() - pod = Pod(client=client, name="my-app", namespace="default") - - debug_info = { - "pod_name": pod.name, - "node": pod.node.name, - "ip": pod.ip, - "status": pod.status, - "logs_tail": pod.log(tail_lines=20), - } - - # Safely attempt commands that might fail - for cmd_name, cmd in [("env", ["env"]), ("df", ["df", "-h"]), ("ps", ["ps", "aux"])]: - try: - debug_info[cmd_name] = pod.execute(command=cmd, timeout=10) - except ExecOnPodError: - debug_info[cmd_name] = "command failed" - - for key, value in debug_info.items(): - print(f"--- {key} ---\n{value}\n") -``` - -### Using Pod Context Manager for Temporary Pods - -Create a pod, run commands, and clean up automatically: - -```python - from ocp_resources.pod import Pod - from ocp_resources.resource import get_client - - client = get_client() - - with Pod( - client=client, - name="debug-pod", - namespace="default", - containers=[ - { - "name": "debug", - "image": "registry.access.redhat.com/ubi9/ubi:latest", - "command": ["sleep", "3600"], - } - ], - ) as pod: - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120) - output = pod.execute(command=["cat", "/etc/os-release"]) - print(output) - # Pod is automatically deleted when the context manager exits -``` - -> **Note:** See [Creating and Managing Resources](creating-and-managing-resources.html) for more about context managers and resource lifecycle. - -## Troubleshooting - -### `ExecOnPodError` with `"stream resp is closed"` - -This typically means the command timed out. Increase the `timeout` parameter: - -```python - pod.execute(command=["long-running-script.sh"], timeout=300) -``` - -### `AssertionError: Node not found for pod ` - -The pod is not yet scheduled. Wait for it to be running before accessing `pod.node`: - -```python - pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120) - node = pod.node -``` - -### `ExecOnPodError` with return code `-1` and an error channel dict - -This indicates a Kubernetes-level failure (e.g., container not found, pod not running). Check that: - -- The pod is in `Running` status -- The `container` name matches one defined in the pod spec -- The container has started and is not in `CrashLoopBackOff` - -### Logs return empty string - -The container may not have produced any output yet, or it may have restarted. Try: - -```python - # Check previous container instance - logs = pod.log(previous=True) - - # Or narrow to recent logs - logs = pod.log(since_seconds=60) -``` - -## Related Pages - -- [Common Resource Patterns](common-patterns.html) -- [Working with Kubernetes Events](working-with-events.html) -- [Error Handling and Exception Reference](error-handling.html) -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) - - ---- - -Source: error-handling.md - -# Error Handling and Exception Reference - -All custom exceptions are defined in `ocp_resources.exceptions` and can be imported directly: - -```python - from ocp_resources.exceptions import ( - ExecOnPodError, - MissingRequiredArgumentError, - ResourceTeardownError, - ValidationError, - ConditionError, - NNCPConfigurationFailed, - ClientWithBasicAuthError, - MissingResourceError, - MissingTemplateVariables, - ) -``` - -> **Tip:** All exceptions inherit from Python's built-in `Exception` class and can be caught with a bare `except Exception` if needed. - ---- - -## ExecOnPodError - -Raised when a command executed inside a pod via `Pod.execute()` fails. See [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) for full usage details. - -**Import:** - -```python - from ocp_resources.exceptions import ExecOnPodError -``` - -**Constructor Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `command` | `list[str]` | The command that was executed | -| `rc` | `int` | Return code (`-1` if the return code could not be determined) | -| `out` | `str` | Standard output captured from the command | -| `err` | `Any` | Standard error output or error channel details | - -**Attributes on the caught exception:** - -| Attribute | Type | Description | -|-----------|------|-------------| -| `cmd` | `list[str]` | The command that was executed | -| `rc` | `int` | The return code | -| `out` | `str` | Standard output | -| `err` | `Any` | Standard error or error channel dict | - -**Raised by:** `Pod.execute()` in the following scenarios: - -- Command times out (stream response closes before completion) — `rc=-1` -- Error channel returns no status — `rc=-1` -- Error channel status is `"Failure"` — `rc=-1`, `err` contains the full error channel dict -- Command exits with a non-zero exit code — `rc` contains the actual exit code - -**String representation:** - -``` - Command execution failure: ['ls', '/nonexistent'], RC: 2, OUT: , ERR: ls: cannot access '/nonexistent' -``` - -**Example:** - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ExecOnPodError - - pod = Pod(client=client, name="my-pod", namespace="default") - - try: - output = pod.execute(command=["ls", "/nonexistent"], timeout=30) - except ExecOnPodError as e: - print(f"Command: {e.cmd}") - print(f"Return code: {e.rc}") - print(f"Stdout: {e.out}") - print(f"Stderr: {e.err}") -``` - -**Handling non-zero exit codes without exceptions:** - -```python - # Use ignore_rc=True to suppress ExecOnPodError on non-zero exit codes - output = pod.execute(command=["grep", "pattern", "/var/log/messages"], ignore_rc=True) -``` - ---- - -## MissingRequiredArgumentError - -Raised when a resource is instantiated without providing required arguments and no `yaml_file` or `kind_dict` was supplied. This error is raised during `to_dict()`, which is called automatically by `create()` and `deploy()`. - -**Import:** - -```python - from ocp_resources.exceptions import MissingRequiredArgumentError -``` - -**Constructor Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `argument` | `str` | Name of the missing required argument(s) | - -**Attributes on the caught exception:** - -| Attribute | Type | Description | -|-----------|------|-------------| -| `argument` | `str` | The missing argument name | - -**String representation:** - -``` - Missing required argument/s. Either provide yaml_file, kind_dict or pass self.containers -``` - -**Raised by:** The `to_dict()` method of resource subclasses when required spec fields are not provided. Examples of resources that raise this exception: - -| Resource Class | Required Arguments | -|---|---| -| `Pod` | `containers` | -| `StorageClass` | `provisioner` | -| `ClusterRoleBinding` | `cluster_role` | -| `ClusterResourceQuota` | `quota`, `selector` | -| `CronJob` | `job_template`, `schedule` | -| `InferenceService` | `predictor` | -| `IPAddressPool` | `addresses` | -| `ResourceQuota` | `hard` | -| `UserDefinedNetwork` | `topology` | - -> **Note:** This exception is **not** raised if you construct the resource using `yaml_file` or `kind_dict`, since those bypass the `to_dict()` logic. - -**Example:** - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import MissingRequiredArgumentError - - try: - pod = Pod(client=client, name="my-pod", namespace="default") - pod.deploy() - except MissingRequiredArgumentError as e: - print(f"Missing: {e.argument}") - # Output: Missing required argument/s. Either provide yaml_file, kind_dict or pass self.containers -``` - ---- - -## ResourceTeardownError - -Raised when a resource's `clean_up()` method returns `False` during context manager exit. This indicates the resource could not be deleted. - -**Import:** - -```python - from ocp_resources.exceptions import ResourceTeardownError -``` - -**Constructor Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `resource` | `Any` | The resource object that failed to tear down | - -**Attributes on the caught exception:** - -| Attribute | Type | Description | -|-----------|------|-------------| -| `resource` | `Any` | The resource object that failed teardown | - -**String representation:** - -``` - Failed to execute teardown for resource -``` - -**Raised by:** `Resource.__exit__()` — the context manager exit handler. Specifically, this is raised when: - -1. The resource was created with `teardown=True` (the default). -2. The `clean_up()` method returns `False`. - -**Example:** - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ResourceTeardownError - - try: - with Pod( - client=client, - name="my-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - ) as pod: - # Use pod... - pass - # clean_up() is called automatically here - except ResourceTeardownError as e: - print(f"Could not delete: {e.resource}") -``` - -> **Tip:** Set `teardown=False` on the resource constructor if you do not want automatic cleanup on context manager exit, and thus never want this exception raised. - ---- - -## ValidationError - -Raised when a resource fails schema validation against the OpenAPI specification. See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for full validation guide. - -**Import:** - -```python - from ocp_resources.exceptions import ValidationError -``` - -**Constructor Parameters:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `message` | `str` | *(required)* | Human-readable error description | -| `path` | `str` | `""` | JSONPath to the invalid field (e.g., `"spec.containers[0].image"`) | -| `schema_error` | `Any` | `None` | Original `jsonschema` validation error for debugging | - -**Attributes on the caught exception:** - -| Attribute | Type | Description | -|-----------|------|-------------| -| `message` | `str` | Human-readable error message | -| `path` | `str` | JSONPath to the invalid field | -| `schema_error` | `Any` | Original `jsonschema.ValidationError` if available | - -**String representation:** - -``` - Validation error at 'spec.containers[0].image': Invalid type -``` - -When `path` is empty: - -``` - Validation error: Field is required -``` - -**Raised by:** - -| Method | Trigger | -|--------|---------| -| `resource.validate()` | Called explicitly by user | -| `resource.create()` | When `schema_validation_enabled=True` | -| `resource.update_replace()` | When `schema_validation_enabled=True` | -| `Resource.validate_dict()` | Class method for validating raw dicts | - -> **Note:** `resource.update()` does **not** trigger validation even when `schema_validation_enabled=True`, because updates send partial patches that would fail full schema validation. - -**Example — explicit validation:** - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ValidationError - - pod = Pod(client=client, name="my-pod", namespace="default", containers=[{"name": "nginx", "image": "nginx:latest"}]) - - try: - pod.validate() - except ValidationError as e: - print(f"Error: {e.message}") - print(f"Path: {e.path}") - if e.schema_error: - print(f"Original error: {e.schema_error}") -``` - -**Example — auto-validation on create:** - -```python - from ocp_resources.pod import Pod - from ocp_resources.exceptions import ValidationError - - try: - pod = Pod( - client=client, - name="my-pod", - namespace="default", - containers=[{"name": "nginx", "image": "nginx:latest"}], - schema_validation_enabled=True, - ) - pod.deploy() - except ValidationError as e: - print(f"Invalid resource: {e}") -``` - -**Example — validate a raw dictionary:** - -```python - from ocp_resources.deployment import Deployment - from ocp_resources.exceptions import ValidationError - - deployment_dict = { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": {"name": "my-deploy"}, - "spec": {"replicas": "three"}, # Wrong type — should be int - } - - try: - Deployment.validate_dict(resource_dict=deployment_dict) - except ValidationError as e: - print(f"Validation failed: {e}") -``` - ---- - -## ConditionError - -Raised when a resource reaches an undesired stop condition during `wait_for_condition()`. See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for details. - -**Import:** - -```python - from ocp_resources.exceptions import ConditionError -``` - -**Constructor Parameters:** Standard `Exception` — accepts a single string message. - -**Raised by:** `Resource.wait_for_condition()` when the `stop_condition` parameter matches before the desired condition is met. - -**`wait_for_condition()` parameters relevant to `ConditionError`:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `condition` | `str` | *(required)* | Condition type to wait for | -| `status` | `str` | *(required)* | Expected status value | -| `timeout` | `int` | `300` | Maximum wait time in seconds | -| `sleep_time` | `int` | `1` | Polling interval in seconds | -| `stop_condition` | `str \| None` | `None` | Condition type that should abort the wait | -| `stop_status` | `str` | `"True"` | Status value for the stop condition | - -**String representation:** - -``` - Deployment my-deploy reached stop_condition 'Failed' in status 'True': - {'type': 'Failed', 'status': 'True', 'reason': 'DeadlineExceeded', 'message': '...'} -``` - -> **Note:** When `stop_condition` is `None` (the default), `ConditionError` is never raised. Instead, `TimeoutExpiredError` is raised if the desired condition is not met within the timeout. - -**Example:** - -```python - from ocp_resources.deployment import Deployment - from ocp_resources.exceptions import ConditionError - from timeout_sampler import TimeoutExpiredError - - deploy = Deployment(client=client, name="my-deploy", namespace="default") - - try: - deploy.wait_for_condition( - condition="Available", - status="True", - timeout=120, - stop_condition="Failed", - stop_status="True", - ) - except ConditionError as e: - print(f"Resource entered failure state: {e}") - except TimeoutExpiredError: - print("Timed out waiting for condition") -``` - ---- - -## NNCPConfigurationFailed - -Raised when a `NodeNetworkConfigurationPolicy` (NNCP) fails to configure on one or more nodes. - -**Import:** - -```python - from ocp_resources.exceptions import NNCPConfigurationFailed -``` - -**Constructor Parameters:** Standard `Exception` — accepts a single string message describing the failure reason and error details. - -**Raised by:** `NodeNetworkConfigurationPolicy.wait_for_status_success()` in two scenarios: - -| Scenario | Message Pattern | -|----------|-----------------| -| No matching node found | `"{name}. Reason: NoMatchingNode"` | -| Configuration failed on nodes | `"Reason: FailedToConfigure\n{error_details}"` | - -> **Note:** When `wait_for_status_success()` catches `TimeoutExpiredError` or `NNCPConfigurationFailed`, it logs the error with node details and re-raises the exception. - -**Example:** - -```python - from ocp_resources.node_network_configuration_policy import NodeNetworkConfigurationPolicy - from ocp_resources.exceptions import NNCPConfigurationFailed - from timeout_sampler import TimeoutExpiredError - - nncp = NodeNetworkConfigurationPolicy( - client=client, - name="my-nncp", - desired_state={"interfaces": [{"name": "eth1", "type": "ethernet", "state": "up"}]}, - ) - - try: - nncp.deploy() - nncp.wait_for_status_success() - except NNCPConfigurationFailed as e: - print(f"NNCP configuration failed: {e}") - except TimeoutExpiredError: - print("NNCP configuration timed out") -``` - ---- - -## ClientWithBasicAuthError - -Raised during OAuth-based client authentication when using username/password credentials to connect to an OpenShift cluster. See [Connecting to Clusters](connecting-to-clusters.html) for connection methods. - -**Import:** - -```python - from ocp_resources.exceptions import ClientWithBasicAuthError -``` - -**Constructor Parameters:** Standard `Exception` — accepts a single string message. - -**Raised by:** `client_configuration_with_basic_auth()` in the following scenarios: - -| Scenario | Message | -|----------|---------| -| OAuth well-known endpoint not reachable | `"No well-known file found at endpoint"` | -| Authorization code not returned after login | `"No authorization code found"` | -| No `authorization_endpoint` in OAuth config | `"No authorization_endpoint found in well-known file"` | -| Token exchange fails | `"Failed to authenticate with basic auth"` | - -**Example:** - -```python - from ocp_resources.exceptions import ClientWithBasicAuthError - from ocp_resources.resource import client_configuration_with_basic_auth - - import kubernetes - - configuration = kubernetes.client.Configuration() - configuration.verify_ssl = False - - try: - api_client = client_configuration_with_basic_auth( - username="admin", - password="password", - host="https://api.cluster.example.com:6443", - configuration=configuration, - ) - except ClientWithBasicAuthError as e: - print(f"Authentication failed: {e}") -``` - ---- - -## MissingResourceError - -Raised when a resource object fails to generate its internal representation. - -**Import:** - -```python - from ocp_resources.exceptions import MissingResourceError -``` - -**Constructor Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `name` | `str` | Name of the resource that could not be generated | - -**Attributes on the caught exception:** - -| Attribute | Type | Description | -|-----------|------|-------------| -| `resource_name` | `str` | The resource name | - -**String representation:** - -``` - Failed to generate resource: my-resource -``` - -**Example:** - -```python - from ocp_resources.exceptions import MissingResourceError - - try: - # Operations that may fail to generate a resource - ... - except MissingResourceError as e: - print(f"Resource generation failed: {e.resource_name}") -``` - ---- - -## MissingTemplateVariables - -Raised when rendering a YAML template and not all required template variables have been provided. - -**Import:** - -```python - from ocp_resources.exceptions import MissingTemplateVariables -``` - -**Constructor Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `var` | `str` | Name of the missing template variable | -| `template` | `str` | Path to the template file | - -**Attributes on the caught exception:** - -| Attribute | Type | Description | -|-----------|------|-------------| -| `var` | `str` | The missing variable name | -| `template` | `str` | The template file path | - -**String representation:** - -``` - Missing variables image for template tests/manifests/vm.yaml -``` - -**Example:** - -```python - from ocp_resources.exceptions import MissingTemplateVariables - - try: - result = generate_yaml_from_template(name="my-vm") - # If template also requires 'image', raises MissingTemplateVariables - except MissingTemplateVariables as e: - print(f"Variable '{e.var}' missing in template '{e.template}'") -``` - ---- - -## Common Error-Handling Patterns - -### Catching Multiple Library Exceptions - -```python - from ocp_resources.exceptions import ( - ExecOnPodError, - MissingRequiredArgumentError, - ResourceTeardownError, - ValidationError, - ) - - try: - with Pod( - client=client, - name="worker", - namespace="default", - containers=[{"name": "app", "image": "myapp:latest"}], - schema_validation_enabled=True, - ) as pod: - pod.execute(command=["python", "run_task.py"]) - except ValidationError as e: - print(f"Invalid resource spec: {e}") - except ExecOnPodError as e: - print(f"Task failed (rc={e.rc}): {e.err}") - except ResourceTeardownError as e: - print(f"Pod cleanup failed: {e}") - except MissingRequiredArgumentError as e: - print(f"Missing field: {e.argument}") -``` - -### Safe Condition Waiting with Early Abort - -```python - from ocp_resources.exceptions import ConditionError - from timeout_sampler import TimeoutExpiredError - - try: - resource.wait_for_condition( - condition="Ready", - status="True", - timeout=180, - stop_condition="Failed", - stop_status="True", - ) - except ConditionError: - # Resource entered a terminal failure state — no point waiting further - resource.clean_up() - raise - except TimeoutExpiredError: - # Condition not met within timeout — may be transient - print("Resource is taking too long, investigating...") -``` - -### Ignoring Non-Critical Command Failures - -```python - from ocp_resources.exceptions import ExecOnPodError - - # Method 1: Use ignore_rc=True - output = pod.execute(command=["grep", "ERROR", "/var/log/app.log"], ignore_rc=True) - - # Method 2: Catch and inspect the return code - try: - output = pod.execute(command=["test", "-f", "/tmp/lockfile"]) - except ExecOnPodError as e: - if e.rc == 1: - print("Lock file does not exist — proceeding") - else: - raise # Re-raise unexpected errors -``` - -### Validation Before Batch Deployment - -```python - from ocp_resources.exceptions import ValidationError - - resources = [pod1, pod2, deployment1, service1] - - # Pre-validate all resources before deploying any - errors = [] - for resource in resources: - try: - resource.validate() - except ValidationError as e: - errors.append((resource.name, str(e))) - - if errors: - for name, err in errors: - print(f" {name}: {err}") - raise SystemExit("Fix validation errors before deploying") - - for resource in resources: - resource.deploy() -``` - ---- - -## Exception Hierarchy Summary - -| Exception | Parent | Module | -|-----------|--------|--------| -| `ExecOnPodError` | `Exception` | `ocp_resources.exceptions` | -| `MissingRequiredArgumentError` | `Exception` | `ocp_resources.exceptions` | -| `ResourceTeardownError` | `Exception` | `ocp_resources.exceptions` | -| `ValidationError` | `Exception` | `ocp_resources.exceptions` | -| `ConditionError` | `Exception` | `ocp_resources.exceptions` | -| `NNCPConfigurationFailed` | `Exception` | `ocp_resources.exceptions` | -| `ClientWithBasicAuthError` | `Exception` | `ocp_resources.exceptions` | -| `MissingResourceError` | `Exception` | `ocp_resources.exceptions` | -| `MissingTemplateVariables` | `Exception` | `ocp_resources.exceptions` | - -> **Warning:** `MissingResourceResError` is deprecated and will be removed in a future release. Use `MissingResourceError` instead. - -## Related Pages - -- [Creating and Managing Resources](creating-and-managing-resources.html) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) -- [Validating Resources Against OpenAPI Schemas](validating-resources.html) -- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) -- [Connecting to Clusters](connecting-to-clusters.html) - - ---- diff --git a/docs/llms.txt b/docs/llms.txt deleted file mode 100644 index a798701f5c..0000000000 --- a/docs/llms.txt +++ /dev/null @@ -1,36 +0,0 @@ -# openshift-python-wrapper - -> Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls - -## Getting Started - -- [Installing and Creating Your First Resource](quickstart.md) -- [Connecting to Clusters](connecting-to-clusters.md) -- [Creating and Managing Resources](creating-and-managing-resources.md) -- [Querying and Watching Resources](querying-resources.md) -- [Waiting for Resource Conditions and Status](waiting-for-conditions.md) -- [Validating Resources Against OpenAPI Schemas](validating-resources.md) -- [Managing Bulk Resources with ResourceList](managing-resource-lists.md) -- [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.md) -- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.md) - -## Testing and Generation - -- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.md) -- [Generating New Resource Classes with class-generator](generating-resource-classes.md) -- [class-generator CLI Reference](class-generator-cli.md) - -## Guides - -- [Common Resource Patterns](common-patterns.md) -- [Integrating with AI Assistants via MCP Server](mcp-server-integration.md) - -## Reference - -- [Resource and NamespacedResource API](resource-api.md) -- [Environment Variables and Configuration](environment-variables.md) -- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.md) -- [Schema Validation and Code Generation Architecture](schema-validation-internals.md) -- [Working with Kubernetes Events](working-with-events.md) -- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.md) -- [Error Handling and Exception Reference](error-handling.md) diff --git a/docs/managing-resource-lists.html b/docs/managing-resource-lists.html deleted file mode 100644 index ba80d183f0..0000000000 --- a/docs/managing-resource-lists.html +++ /dev/null @@ -1,560 +0,0 @@ - - - - - - Managing Bulk Resources with ResourceList - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Managing Bulk Resources with ResourceList

-

When you need to create several copies of the same resource or deploy a resource across multiple namespaces, doing it one at a time is tedious and error-prone. ResourceList and NamespacedResourceList let you create, deploy, and clean up groups of resources in a single operation.

-

Prerequisites

- -

Quick Example

-

Create three namespaces and deploy them in two lines:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import ResourceList, get_client
-
-client = get_client()
-
-namespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name="my-ns")
-namespaces.deploy()
-# Creates: my-ns-1, my-ns-2, my-ns-3
-
- -

Or use a context manager for automatic cleanup:

-
with ResourceList(client=client, resource_class=Namespace, name="my-ns", num_resources=3) as namespaces:
-    print(namespaces[0].name)  # "my-ns-1"
-# All three namespaces are deleted when the block exits
-
- -

Creating Multiple Copies of a Resource

-

ResourceList creates N copies of a resource type, each with an auto-generated name based on a base name you provide.

-

Step 1: Choose a resource class and specify how many copies you want:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import ResourceList, get_client
-
-client = get_client()
-namespaces = ResourceList(
-    resource_class=Namespace,
-    num_resources=3,
-    client=client,
-    name="test-ns",
-)
-
- -

This creates three Namespace objects named test-ns-1, test-ns-2, and test-ns-3. They are not yet deployed to the cluster.

-

Step 2: Deploy all resources:

-
namespaces.deploy()
-
- -

Step 3: Work with individual resources by index or iteration:

-
# Access by index
-first_ns = namespaces[0]
-print(first_ns.name)  # "test-ns-1"
-
-# Iterate over all resources
-for ns in namespaces:
-    print(ns.name)
-
-# Check how many resources are in the list
-print(len(namespaces))  # 3
-
- -

Step 4: Clean up all resources when done:

-
namespaces.clean_up()
-
- -
-

Note: clean_up() deletes resources in reverse order. This ensures dependent resources are removed before the resources they depend on.

-
-

Deploying One Resource Per Namespace

-

NamespacedResourceList creates one instance of a namespaced resource in each namespace from a ResourceList.

-
from ocp_resources.namespace import Namespace
-from ocp_resources.pod import Pod
-from ocp_resources.resource import NamespacedResourceList, ResourceList, get_client
-
-client = get_client()
-
-# First, create the namespaces
-namespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name="app-ns")
-namespaces.deploy()
-
-# Then create one pod in each namespace
-pods = NamespacedResourceList(
-    client=client,
-    resource_class=Pod,
-    namespaces=namespaces,
-    name="web-server",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-)
-pods.deploy()
-
- -

This creates three pods, all named web-server, one in each namespace (app-ns-1, app-ns-2, app-ns-3).

-
for pod in pods:
-    print(f"{pod.name} in {pod.namespace}")
-# web-server in app-ns-1
-# web-server in app-ns-2
-# web-server in app-ns-3
-
- -
-

Warning: The namespaces parameter must be a ResourceList containing only Namespace resources. Passing any other resource type raises a TypeError.

-
-

Passing Extra Resource Parameters

-

Any additional keyword arguments you pass are forwarded to each resource's constructor. This lets you configure resources beyond just the name:

-
from ocp_resources.role import Role
-from ocp_resources.resource import NamespacedResourceList
-
-roles = NamespacedResourceList(
-    client=client,
-    resource_class=Role,
-    namespaces=namespaces,
-    name="viewer-role",
-    rules=[
-        {
-            "apiGroups": [""],
-            "resources": ["pods"],
-            "verbs": ["get", "list", "watch"],
-        }
-    ],
-)
-roles.deploy()
-
- -

Advanced Usage

-

Context Managers for Automatic Lifecycle

-

Both ResourceList and NamespacedResourceList support context managers. Resources are deployed on entry and cleaned up on exit — ideal for tests:

-
with ResourceList(client=client, resource_class=Namespace, name="test-ns", num_resources=3) as namespaces:
-    with NamespacedResourceList(
-        client=client,
-        resource_class=Pod,
-        namespaces=namespaces,
-        name="test-pod",
-        containers=[{"name": "app", "image": "busybox"}],
-    ) as pods:
-        # All namespaces and pods are deployed
-        assert len(pods) == 3
-    # Pods are cleaned up here
-# Namespaces are cleaned up here
-
- -
-

Tip: Nest the context managers so that namespaced resources are cleaned up before their namespaces are deleted.

-
-

Waiting During Deploy and Cleanup

-

Pass wait=True to deploy() to wait for each resource to reach a ready state. By default, clean_up() already waits for deletion to complete.

-
namespaces = ResourceList(resource_class=Namespace, num_resources=2, client=client, name="ns")
-namespaces.deploy(wait=True)
-
-# Later...
-namespaces.clean_up(wait=True)  # wait=True is the default
-namespaces.clean_up(wait=False)  # Skip waiting for deletion
-
- -

For more on waiting for specific conditions, see Waiting for Resource Conditions and Status.

-

Comparison: ResourceList vs NamespacedResourceList

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FeatureResourceListNamespacedResourceList
PurposeCreate N copies of any resourceCreate one resource per namespace
NamingAuto-generates {name}-1, {name}-2, …Same name in each namespace
Required paramsresource_class, num_resources, nameresource_class, namespaces, name
Use caseMultiple namespaces, nodes, cluster-scoped resourcesPods, services, roles — anything namespace-scoped
Context manager✅✅
-

Troubleshooting

-

TypeError: "All the resources in namespaces should be namespaces"

-

You passed a ResourceList that contains non-Namespace resources to NamespacedResourceList. Make sure the namespaces parameter is a ResourceList created with resource_class=Namespace.

-

Resources not appearing on the cluster

-

Creating a ResourceList or NamespacedResourceList only instantiates the Python objects — it does not deploy them. Call .deploy() or use a context manager (with ... as) to actually create the resources on the cluster.

-

Cleanup order issues

-

Always clean up namespaced resources before their namespaces. When using context managers, nest them so the inner block (namespaced resources) exits first. When calling clean_up() manually, clean up dependent resources before their parents.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/mcp-server-integration.html b/docs/mcp-server-integration.html deleted file mode 100644 index d38d7763d5..0000000000 --- a/docs/mcp-server-integration.html +++ /dev/null @@ -1,753 +0,0 @@ - - - - - - Integrating with AI Assistants via MCP Server - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Integrating with AI Assistants via MCP Server

-

The openshift-python-wrapper ships with a built-in Model Context Protocol (MCP) server that lets AI assistants like Cursor, Claude Desktop, and other MCP-compatible tools directly manage your OpenShift/Kubernetes cluster resources.

-

Configure the MCP Server for Cursor

-

Connect your Cursor IDE to your cluster so the AI assistant can list, create, update, and delete resources on your behalf.

-
// ~/.cursor/mcp.json
-{
-  "mcpServers": {
-    "openshift-python-wrapper": {
-      "command": "openshift-mcp-server"
-    }
-  }
-}
-
- -

After saving, restart Cursor and reference the server with @openshift-python-wrapper in your prompts. The server uses your active kubeconfig context automatically.

-
-

Note: The openshift-mcp-server command is installed as a script entry point when you install the package. See Installing and Creating Your First Resource for installation instructions.

-
-

Configure the MCP Server for Claude Desktop

-

Set up Claude Desktop to interact with your cluster through natural language.

-
// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
-// Windows: %APPDATA%\Claude\claude_desktop_config.json
-// Linux: ~/.config/Claude/claude_desktop_config.json
-{
-  "mcpServers": {
-    "openshift-python-wrapper": {
-      "command": "openshift-mcp-server"
-    }
-  }
-}
-
- -

Claude Desktop will connect to the server on startup. You can then ask it to list pods, create resources, check logs, and more using natural language.

-

Configure a Custom Kubeconfig Path

-

Point the MCP server at a specific kubeconfig when your default location doesn't apply.

-
{
-  "mcpServers": {
-    "openshift-python-wrapper": {
-      "command": "openshift-mcp-server",
-      "env": {
-        "KUBECONFIG": "/home/user/.kube/my-cluster-config"
-      }
-    }
-  }
-}
-
- -

The KUBECONFIG environment variable is passed to the server process at startup. This is useful when managing multiple clusters or when your kubeconfig is in a non-standard location.

-
-

Tip: See Connecting to Clusters for all authentication methods supported by openshift-python-wrapper, including tokens, basic auth, and in-cluster config.

-
-

Run the MCP Server from Source (Development)

-

Run the server directly from a cloned repository for development or testing.

-
git clone https://github.com/RedHatQE/openshift-python-wrapper.git
-cd openshift-python-wrapper
-uv run mcp_server/server.py
-
- -

For MCP client configuration when running from source:

-
{
-  "mcpServers": {
-    "openshift-python-wrapper": {
-      "command": "uv",
-      "args": ["run", "mcp_server/server.py"],
-      "cwd": "/path/to/openshift-python-wrapper"
-    }
-  }
-}
-
- -

This bypasses the installed entry point and runs the server module directly, which is useful when testing local changes.

-

List Cluster Resources via AI Chat

-

Ask your AI assistant to list resources — the MCP server exposes a list_resources tool that supports type filtering, namespaces, label selectors, and limits.

-
Prompt: "List all pods in the openshift-monitoring namespace with label app=prometheus"
-
- -

The AI will call the list_resources tool, which maps to:

-
list_resources(resource_type="pod", namespace="openshift-monitoring", label_selector="app=prometheus", limit=20)
-
- -

Returned data includes name, namespace, UID, creation timestamp, labels, phase, and conditions for each matched resource.

-
-

Tip: Use get_resource_types to discover all available resource types. The server dynamically scans every module in ocp_resources/ at startup, so any resource supported by the library — including OpenShift-specific types like route, virtualmachine, or clusterserviceversion — is available.

-
-

Get Detailed Resource Information

-

Retrieve a single resource with full metadata, status, and optionally the raw YAML or JSON.

-
Prompt: "Show me the deployment 'api-server' in namespace 'production' as YAML"
-
- -

The AI calls:

-
get_resource(
-    resource_type="deployment",
-    name="api-server",
-    namespace="production",
-    output_format="yaml",  # also accepts "json" or "info"
-)
-
- -

The info format (default) returns structured metadata plus resource-specific details: for pods you get container statuses and node placement; for deployments you get replica counts; for services you get ports and cluster IP.

-

Create Resources Through Natural Language

-

Ask the AI to create resources — it can use either YAML content or structured spec parameters.

-
Prompt: "Create a ConfigMap called 'app-settings' in the 'staging' namespace with keys
-database_host=db.staging.svc and log_level=debug"
-
- -

The AI calls:

-
create_resource(
-    resource_type="configmap",
-    name="app-settings",
-    namespace="staging",
-    spec={"data": {"database_host": "db.staging.svc", "log_level": "debug"}},
-    labels={"managed-by": "mcp-server"},
-)
-
- -

You can also provide full YAML:

-
create_resource(
-    resource_type="pod",
-    name="nginx",
-    yaml_content="""
-apiVersion: v1
-kind: Pod
-metadata:
-  name: nginx
-  namespace: default
-spec:
-  containers:
-  - name: nginx
-    image: nginx:1.27
-    ports:
-    - containerPort: 80
-""",
-)
-
- -
-

Warning: The MCP server creates real resources on your cluster. Make sure your AI assistant is targeting the correct cluster and namespace before confirming creation operations.

-
-

Apply Multi-Document YAML Manifests

-

Deploy a complete application stack from a single YAML block containing multiple resources.

-
Prompt: "Apply this manifest to create a namespace, deployment, and service for my app"
-
- -

The AI calls:

-
apply_yaml(
-    yaml_content="""
----
-apiVersion: v1
-kind: Namespace
-metadata:
-  name: my-app
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: backend
-  namespace: my-app
-spec:
-  replicas: 3
-  selector:
-    matchLabels:
-      app: backend
-  template:
-    metadata:
-      labels:
-        app: backend
-    spec:
-      containers:
-      - name: api
-        image: myapp/backend:v1.0
-        ports:
-        - containerPort: 8080
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: backend
-  namespace: my-app
-spec:
-  selector:
-    app: backend
-  ports:
-  - port: 80
-    targetPort: 8080
-"""
-)
-
- -

The response includes a summary with total_resources, successful, and failed counts, plus per-resource results. Each YAML document is parsed and deployed independently.

-

Update Resources with Patches

-

Modify existing resources using merge or strategic-merge patches.

-
Prompt: "Scale the 'web-frontend' deployment in 'production' to 5 replicas"
-
- -

The AI calls:

-
update_resource(
-    resource_type="deployment",
-    name="web-frontend",
-    namespace="production",
-    patch={"spec": {"replicas": 5}},
-    patch_type="merge",  # or "strategic"
-)
-
- -

The patch_type parameter controls the content type: "merge" uses application/merge-patch+json, while "strategic" uses application/strategic-merge-patch+json.

-

Delete Resources

-

Remove resources with optional wait-for-deletion behavior.

-
Prompt: "Delete the pod 'debug-pod' in namespace 'default' and wait for it to be gone"
-
- -

The AI calls:

-
delete_resource(resource_type="pod", name="debug-pod", namespace="default", wait=True, timeout=60)
-
- -

If the resource doesn't exist, the server returns a success response with a warning rather than an error, making the operation idempotent.

-

Retrieve Pod Logs

-

Fetch logs from running or crashed pods with filtering options.

-
Prompt: "Show me the last 200 lines of logs from pod 'api-server-7f8b9c' container 'app'
-in namespace 'production'"
-
- -

The AI calls:

-
get_pod_logs(name="api-server-7f8b9c", namespace="production", container="app", tail_lines=200)
-
- -
    -
  • Use since_seconds=3600 to get logs from the last hour
  • -
  • Use previous=True to get logs from a crashed container's previous instance
  • -
  • Omit container for single-container pods
  • -
-

Execute Commands in Pods

-

Run diagnostic commands inside a running pod container.

-
Prompt: "Check the nginx config in pod 'web-server' namespace 'production'"
-
- -

The AI calls:

-
exec_in_pod(name="web-server", namespace="production", command=["nginx", "-t"], container="nginx")
-
- -

The response includes stdout, stderr, and returncode. If the command fails, the exit code and error output are captured rather than raising an exception.

-
-

Warning: Pod exec gives shell-level access to your containers. Ensure your RBAC policies restrict which service accounts and users can perform exec operations.

-
-

Get Resource Events

-

Retrieve Kubernetes events related to a specific resource for troubleshooting.

-
Prompt: "Show me the events for pod 'crashloop-pod' in 'default' namespace"
-
- -

The AI calls:

-
get_resource_events(resource_type="pod", name="crashloop-pod", namespace="default", limit=10)
-
- -

Events are filtered using field selectors on involvedObject.name, involvedObject.namespace, and involvedObject.kind. Each event includes type (Normal/Warning), reason, message, count, timestamps, and source component.

-

Troubleshoot a Failing Pod (End-to-End Workflow)

-

Combine multiple MCP tools in sequence to diagnose pod issues — the AI assistant will chain these calls automatically.

-
Prompt: "Pod 'payment-svc-6d4f8' in namespace 'checkout' keeps crashing. Help me figure out why."
-
- -

The AI will typically execute this sequence:

-
    -
  1. Check status: get_resource(resource_type="pod", name="payment-svc-6d4f8", namespace="checkout") — gets phase, container statuses, restart count
  2. -
  3. Review events: get_resource_events(resource_type="pod", name="payment-svc-6d4f8", namespace="checkout") — shows scheduling, pulling, crash events
  4. -
  5. Read current logs: get_pod_logs(name="payment-svc-6d4f8", namespace="checkout", tail_lines=100) — application output before crash
  6. -
  7. Read previous crash logs: get_pod_logs(name="payment-svc-6d4f8", namespace="checkout", previous=True) — logs from the last terminated container
  8. -
  9. Inspect config: exec_in_pod(name="payment-svc-6d4f8", namespace="checkout", command=["cat", "/etc/app/config.yaml"]) — verify mounted configuration
  10. -
-

The AI then synthesizes all the data into a diagnosis with suggested fixes.

-

Write a Programmatic MCP Client

-

Connect to the MCP server programmatically using FastMCPClient for automation scripts.

-
import asyncio
-from fastmcp import FastMCPClient
-
-
-async def main():
-    async with FastMCPClient() as client:
-        await client.connect_stdio(cmd=["openshift-mcp-server"])
-
-        # Discover available resource types
-        types = await client.call_tool(
-            name="get_resource_types",
-            arguments={"random_string": "x"},
-        )
-        print(f"Available types: {types['total_count']}")
-
-        # List pods in a namespace
-        pods = await client.call_tool(
-            name="list_resources",
-            arguments={"resource_type": "pod", "namespace": "default", "limit": 5},
-        )
-        for pod in pods["resources"]:
-            print(f"  {pod['name']} — {pod.get('phase', 'Unknown')}")
-
-
-if __name__ == "__main__":
-    asyncio.run(main())
-
- -

This uses the same stdio transport that AI assistants use. The FastMCPClient is provided by the fastmcp library, which is already a dependency of openshift-python-wrapper.

-

Debug MCP Server Issues

-

Enable debug logging and inspect the server log file when tools aren't behaving as expected.

-
# Check the MCP server log file
-tail -f /tmp/mcp_server_debug.log
-
- -
# Run with debug-level logging from the simple-logger
-SIMPLE_LOGGER_LEVEL=DEBUG openshift-mcp-server
-
- -

The server writes debug logs to /tmp/mcp_server_debug.log by default, including every resource scan at startup, client creation events, and detailed error tracebacks.

-
    -
  • Verify cluster connectivity independently: kubectl cluster-info
  • -
  • Check your permissions: kubectl auth can-i --list
  • -
  • Confirm the entry point is installed: which openshift-mcp-server
  • -
-
-

Tip: See Environment Variables and Configuration for other environment variables that affect library behavior.

-
-

Available MCP Tools Reference

-

The MCP server exposes ten tools. All resource type names are case-insensitive (e.g., "Pod", "pod", and "POD" all work).

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ToolPurposeRequired Parameters
list_resourcesList resources by type with optional filtersresource_type
get_resourceGet a single resource by nameresource_type, name
create_resourceCreate a resource from spec or YAMLresource_type, name, plus spec or yaml_content
update_resourcePatch an existing resourceresource_type, name, patch
delete_resourceDelete a resourceresource_type, name
get_pod_logsRetrieve pod container logsname, namespace
exec_in_podExecute a command in a podname, namespace, command
get_resource_eventsGet events for a resourceresource_type, name
apply_yamlApply one or more YAML documentsyaml_content
get_resource_typesList all discovered resource typesrandom_string (any value)
-
-

Note: Namespaced resources (pods, deployments, configmaps, etc.) require a namespace parameter. Cluster-scoped resources (nodes, namespaces, cluster roles) do not. The server validates this automatically and returns a clear error if a namespace is missing.

-
- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/pod-execution-and-logs.html b/docs/pod-execution-and-logs.html deleted file mode 100644 index 8e43b4b312..0000000000 --- a/docs/pod-execution-and-logs.html +++ /dev/null @@ -1,765 +0,0 @@ - - - - - - Executing Commands in Pods and Retrieving Logs - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Executing Commands in Pods and Retrieving Logs

-

Run commands inside running pods and retrieve their logs to debug and interact with workloads on your OpenShift or Kubernetes cluster.

-

Prerequisites

-
    -
  • A connected cluster client (see Connecting to Clusters)
  • -
  • An existing, running pod you want to interact with
  • -
-

Quick Example

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-pod = Pod(client=client, name="my-app", namespace="default")
-
-# Run a command inside the pod
-output = pod.execute(command=["echo", "hello"])
-print(output)  # "hello\n"
-
-# Get pod logs
-logs = pod.log()
-print(logs)
-
- -

Executing Commands with Pod.execute()

-

Pod.execute() runs a command inside a pod container using the Kubernetes exec API and returns the standard output as a string.

-

Method Signature

-
Pod.execute(
-    command: list[str],
-    timeout: int = 60,
-    container: str = "",
-    ignore_rc: bool = False,
-) -> str
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
commandlist[str]—The command to run, as a list of strings (e.g., ["ls", "-la", "/tmp"])
timeoutint60Maximum seconds to wait for the command to complete
containerstr""Container name to execute in. If empty, uses the first container in the pod spec
ignore_rcboolFalseWhen True, return stdout even if the command exits with a non-zero return code
-

Returns: str — the standard output of the command.

-

Raises: ExecOnPodError — when the command fails (non-zero exit code) and ignore_rc is False.

-

Step-by-Step: Running a Simple Command

-
    -
  1. Get a reference to the pod:
  2. -
-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-pod = Pod(client=client, name="my-app", namespace="my-namespace")
-
- -
    -
  1. Execute a command:
  2. -
-
output = pod.execute(command=["cat", "/etc/hostname"])
-print(output)
-
- -
    -
  1. Use the result for further logic:
  2. -
-
files = pod.execute(command=["ls", "/app/data"])
-for filename in files.strip().split("\n"):
-    print(f"Found: {filename}")
-
- -

Selecting a Container

-

For multi-container pods, specify which container to run the command in. If you omit container, the first container defined in the pod spec is used.

-
# Execute in a specific container
-output = pod.execute(
-    command=["cat", "/var/log/app.log"],
-    container="sidecar",
-)
-
- -

Setting a Timeout

-

Long-running commands may need a longer timeout. The default is 60 seconds.

-
# Allow up to 5 minutes for a heavy operation
-output = pod.execute(
-    command=["pg_dump", "mydb"],
-    timeout=300,
-)
-
- -

When the timeout is exceeded, an ExecOnPodError is raised with the error message "stream resp is closed".

-

Ignoring Non-Zero Exit Codes

-

Some commands return a non-zero exit code as part of normal operation (e.g., grep returns 1 when no match is found). Use ignore_rc=True to get the output regardless:

-
# grep returns exit code 1 when there's no match — don't treat that as an error
-output = pod.execute(
-    command=["grep", "ERROR", "/var/log/app.log"],
-    ignore_rc=True,
-)
-if output:
-    print("Errors found:", output)
-else:
-    print("No errors in logs")
-
- -

Handling Errors with ExecOnPodError

-

When a command fails, ExecOnPodError provides structured access to the failure details.

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ExecOnPodError
-
-try:
-    pod.execute(command=["ls", "/nonexistent"])
-except ExecOnPodError as e:
-    print(f"Command: {e.cmd}")  # ['ls', '/nonexistent']
-    print(f"Return code: {e.rc}")  # Non-zero exit code (or -1 for stream errors)
-    print(f"Stdout: {e.out}")  # Standard output captured before failure
-    print(f"Stderr: {e.err}")  # Standard error or error channel details
-
- -

ExecOnPodError attributes:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttributeTypeDescription
cmdlist[str]The command that was executed
rcintReturn code (-1 for stream/timeout errors, otherwise the exit code)
outstrStandard output captured from the command
errstr or dictStandard error output, or the Kubernetes error channel response
-
-

Tip: For a complete list of all custom exceptions, see Error Handling and Exception Reference.

-
-

Retrieving Logs with Pod.log()

-

Pod.log() returns the logs from a pod container as a string. It passes keyword arguments directly to the Kubernetes read_namespaced_pod_log API.

-

Basic Usage

-
logs = pod.log()
-print(logs)
-
- -

Common Keyword Arguments

-

Pass any parameter supported by the Kubernetes read_namespaced_pod_log API:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
containerstrContainer name to get logs from (required for multi-container pods)
previousboolReturn logs from a previous terminated container instance
tail_linesintNumber of lines from the end of the logs to return
since_secondsintOnly return logs newer than this many seconds
-

Examples

-
# Get logs from a specific container
-logs = pod.log(container="nginx")
-
-# Get the last 50 lines
-logs = pod.log(tail_lines=50)
-
-# Get logs from the last 5 minutes
-logs = pod.log(since_seconds=300)
-
-# Get logs from a crashed container's previous instance
-logs = pod.log(container="app", previous=True)
-
- -

Accessing Pod Properties

-

Beyond executing commands and reading logs, the Pod class provides properties for inspecting the pod's runtime state.

-

Getting the Node

-

The node property returns a Node object for the node where the pod is scheduled:

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-for pod in Pod.get(client=client, label_selector="app=my-app"):
-    node = pod.node
-    print(f"Pod {pod.name} is running on node {node.name}")
-
- -
-

Note: The node property raises an AssertionError if the pod has not yet been scheduled to a node.

-
-

Getting the Pod IP Address

-

The ip property returns the pod's IP address from its status:

-
pod_ip = pod.ip
-print(f"Pod IP: {pod_ip}")
-
- -

Getting the Pod Status

-

The status property (inherited from the base resource class) returns the pod's phase:

-
print(f"Pod status: {pod.status}")  # e.g., "Running", "Pending", "Succeeded"
-
- -

Advanced Usage

-

Iterating Over Pods with Selectors

-

Combine Pod.get() with execute() or log() to debug across multiple pods:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ExecOnPodError
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-for pod in Pod.get(client=client, namespace="production", label_selector="app=web"):
-    try:
-        uptime = pod.execute(command=["uptime"])
-        print(f"{pod.name} on {pod.node.name}: {uptime.strip()}")
-    except ExecOnPodError as e:
-        print(f"{pod.name}: command failed — {e}")
-
- -

Executing in Multi-Container Pods

-

When pods contain sidecar containers (e.g., logging or proxy containers), always specify the target container explicitly:

-
# Main application container
-app_output = pod.execute(command=["cat", "/app/config.yaml"], container="app")
-
-# Envoy sidecar
-proxy_stats = pod.execute(command=["curl", "localhost:15000/stats"], container="istio-proxy")
-
-# Logs from each container
-app_logs = pod.log(container="app", tail_lines=100)
-proxy_logs = pod.log(container="istio-proxy", tail_lines=100)
-
- -

Collecting Debug Information

-

A practical recipe for gathering debugging data from a running pod:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ExecOnPodError
-from ocp_resources.resource import get_client
-
-client = get_client()
-pod = Pod(client=client, name="my-app", namespace="default")
-
-debug_info = {
-    "pod_name": pod.name,
-    "node": pod.node.name,
-    "ip": pod.ip,
-    "status": pod.status,
-    "logs_tail": pod.log(tail_lines=20),
-}
-
-# Safely attempt commands that might fail
-for cmd_name, cmd in [("env", ["env"]), ("df", ["df", "-h"]), ("ps", ["ps", "aux"])]:
-    try:
-        debug_info[cmd_name] = pod.execute(command=cmd, timeout=10)
-    except ExecOnPodError:
-        debug_info[cmd_name] = "command failed"
-
-for key, value in debug_info.items():
-    print(f"--- {key} ---\n{value}\n")
-
- -

Using Pod Context Manager for Temporary Pods

-

Create a pod, run commands, and clean up automatically:

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-with Pod(
-    client=client,
-    name="debug-pod",
-    namespace="default",
-    containers=[
-        {
-            "name": "debug",
-            "image": "registry.access.redhat.com/ubi9/ubi:latest",
-            "command": ["sleep", "3600"],
-        }
-    ],
-) as pod:
-    pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)
-    output = pod.execute(command=["cat", "/etc/os-release"])
-    print(output)
-# Pod is automatically deleted when the context manager exits
-
- -
-

Note: See Creating and Managing Resources for more about context managers and resource lifecycle.

-
-

Troubleshooting

-

ExecOnPodError with "stream resp is closed"

-

This typically means the command timed out. Increase the timeout parameter:

-
pod.execute(command=["long-running-script.sh"], timeout=300)
-
- -

AssertionError: Node not found for pod <name>

-

The pod is not yet scheduled. Wait for it to be running before accessing pod.node:

-
pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)
-node = pod.node
-
- -

ExecOnPodError with return code -1 and an error channel dict

-

This indicates a Kubernetes-level failure (e.g., container not found, pod not running). Check that:

-
    -
  • The pod is in Running status
  • -
  • The container name matches one defined in the pod spec
  • -
  • The container has started and is not in CrashLoopBackOff
  • -
-

Logs return empty string

-

The container may not have produced any output yet, or it may have restarted. Try:

-
# Check previous container instance
-logs = pod.log(previous=True)
-
-# Or narrow to recent logs
-logs = pod.log(since_seconds=60)
-
- - - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/querying-resources.html b/docs/querying-resources.html deleted file mode 100644 index 427880d632..0000000000 --- a/docs/querying-resources.html +++ /dev/null @@ -1,1028 +0,0 @@ - - - - - - Querying and Watching Resources - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Querying and Watching Resources

-

Find, filter, and monitor Kubernetes and OpenShift resources in your cluster — whether you need to list all pods by label, check if a resource exists, watch for real-time changes, or inspect status and conditions.

-

Prerequisites

- -

Quick Example

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-# List all pods in a namespace
-for pod in Pod.get(client=client, namespace="my-namespace"):
-    print(f"{pod.name} — {pod.status}")
-
- -

Listing Resources

-

Every resource class provides a get() class method that returns a generator of resource objects. The method accepts standard Kubernetes query parameters as keyword arguments.

-

List all resources of a type

-
from ocp_resources.namespace import Namespace
-
-for ns in Namespace.get(client=client):
-    print(ns.name)
-
- -

Filter by namespace

-

Namespaced resources (like Pod, Service, ConfigMap) accept a namespace keyword:

-
from ocp_resources.pod import Pod
-
-for pod in Pod.get(client=client, namespace="default"):
-    print(pod.name)
-
- -

Filter by label selector

-

Use label_selector with standard Kubernetes label selector syntax:

-
from ocp_resources.pod import Pod
-
-# Single label
-for pod in Pod.get(client=client, namespace="my-app", label_selector="app=nginx"):
-    print(pod.name)
-
-# Multiple labels (comma-separated)
-for pod in Pod.get(client=client, label_selector="app=nginx,tier=frontend"):
-    print(pod.name)
-
- -

Filter by field selector

-

Use field_selector with standard Kubernetes field selector syntax:

-
from ocp_resources.pod import Pod
-
-# Find pods on a specific node
-for pod in Pod.get(client=client, field_selector="spec.nodeName=worker-1"):
-    print(pod.name)
-
-# Find pods by status phase
-for pod in Pod.get(client=client, namespace="default", field_selector="status.phase=Running"):
-    print(pod.name)
-
- -

Get raw resource objects

-

By default, get() returns wrapper objects. Pass raw=True to get the underlying ResourceInstance or ResourceField objects directly:

-
from ocp_resources.pod import Pod
-
-for pod in Pod.get(client=client, namespace="default", raw=True):
-    # Returns raw ResourceField objects instead of Pod instances
-    print(pod.metadata.name, pod.status.phase)
-
- -

List all cluster resources

-

To iterate over every resource type in the cluster:

-
from ocp_resources.resource import Resource
-
-for resource in Resource.get_all_cluster_resources(client=client):
-    print(f"{resource.kind}: {resource.metadata.name}")
-
-# Filter with label selector
-for resource in Resource.get_all_cluster_resources(client=client, label_selector="my-label=value"):
-    print(f"{resource.kind}: {resource.metadata.name}")
-
- -

get() Method Reference

-
@classmethod
-def get(
-    cls,
-    client: DynamicClient | None = None,
-    singular_name: str = "",
-    exceptions_dict: dict[type[Exception], list[str]] = DEFAULT_CLUSTER_RETRY_EXCEPTIONS,
-    raw: bool = False,
-    *args,
-    **kwargs,
-) -> Generator
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
clientDynamicClientKubernetes dynamic client (required)
singular_namestrResource kind in lowercase; used when multiple resources match the same kind
rawboolIf True, return raw ResourceInstance/ResourceField objects instead of wrapper instances
exceptions_dictdictExceptions to retry on during API calls
**kwargsPassed through to the Kubernetes API: namespace, label_selector, field_selector, limit, resource_version, timeout_seconds, etc.
-

Returns: A generator yielding resource objects.

-

Checking Resource Existence

-

The exists property queries the API server and returns the resource instance if it exists, or None if it does not.

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="my-namespace")
-
-if ns.exists:
-    print("Namespace exists")
-else:
-    print("Namespace not found")
-
- -

Require existence at initialization

-

Use ensure_exists=True to raise ResourceNotFoundError immediately if the resource is not found:

-
from ocp_resources.pod import Pod
-
-# Raises ResourceNotFoundError if the pod doesn't exist
-pod = Pod(client=client, name="my-pod", namespace="default", ensure_exists=True)
-
- -
-

Tip: Use ensure_exists=True when you need a reference to a resource that must already be on the cluster, such as a pre-existing ConfigMap or Secret.

-
-

Getting the Resource Instance

-

The instance property fetches the full live resource from the API server:

-
pod = Pod(client=client, name="my-pod", namespace="default")
-
-# Get the full resource instance
-inst = pod.instance
-print(inst.metadata.name)
-print(inst.metadata.uid)
-print(inst.metadata.resourceVersion)
-
-# Convert to a plain dictionary
-resource_dict = pod.instance.to_dict()
-
- -
-

Note: Each access to .instance makes an API call. Cache the result in a local variable if you need multiple fields from the same snapshot.

-
-

Retrieving Labels

-

The labels property returns the resource's labels:

-
ns = Namespace(client=client, name="my-namespace")
-print(ns.labels)  # {'kubernetes.io/metadata.name': 'my-namespace', ...}
-
-# Check a specific label
-if "app" in ns.labels:
-    print(f"App label: {ns.labels['app']}")
-
- -

Retrieving Resource Status

-

Status phase

-

The status property returns the current phase (e.g., Running, Pending, Active):

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="my-namespace")
-print(ns.status)  # "Active"
-
- -

Each resource class defines status constants you can compare against:

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-if pod.status == Pod.Status.RUNNING:
-    print("Pod is running")
-
- -

Common status constants available on all resources:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConstantValue
Status.RUNNING"Running"
Status.PENDING"Pending"
Status.SUCCEEDED"Succeeded"
Status.FAILED"Failed"
Status.ACTIVE"Active"
Status.TERMINATING"Terminating"
Status.COMPLETED"Completed"
Status.ERROR"Error"
-

Condition messages

-

Use get_condition_message() to retrieve the message for a specific condition type:

-
pod = Pod(client=client, name="my-pod", namespace="default")
-
-# Get message for a condition type
-msg = pod.get_condition_message(condition_type="Ready")
-print(msg)
-
-# Optionally filter by condition status
-msg = pod.get_condition_message(
-    condition_type="Ready",
-    condition_status="False",
-)
-print(msg)  # Returns empty string if status doesn't match
-
- -

Signature:

-
def get_condition_message(
-    self,
-    condition_type: str,
-    condition_status: str = "",
-) -> str
-
- -
- - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
condition_typestrThe condition type to look up (e.g., "Ready", "Available")
condition_statusstrIf provided, only return the message when the condition has this status
-

Returns: The condition message string, or "" if not found or status doesn't match.

-

Condition constants

-

Resources provide built-in condition constants:

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-
-# Use condition constants
-msg = pod.get_condition_message(
-    condition_type=Pod.Condition.READY,
-    condition_status=Pod.Condition.Status.TRUE,
-)
-
- -

Common condition constants:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConstantValue
Condition.READY"Ready"
Condition.AVAILABLE"Available"
Condition.PROGRESSING"Progressing"
Condition.DEGRADED"Degraded"
Condition.UPGRADEABLE"Upgradeable"
Condition.Status.TRUE"True"
Condition.Status.FALSE"False"
Condition.Status.UNKNOWN"Unknown"
-

Watching for Changes

-

Watch a specific resource

-

The watcher() method streams events for a specific resource instance:

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-
-for event in pod.watcher(timeout=60):
-    print(f"Event type: {event['type']}")  # ADDED, MODIFIED, DELETED
-    print(f"Object: {event['object'].metadata.name}")
-    print(f"Raw: {event['raw_object']}")
-
- -

Signature:

-
def watcher(
-    self,
-    timeout: int,
-    resource_version: str = "",
-) -> Generator[dict[str, Any], None, None]
-
- -
- - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
timeoutintHow long to watch (in seconds)
resource_versionstrOnly receive events newer than this version. Defaults to the resource version at creation time.
-

Each yielded event is a dict with these keys:

-
- - - - - - - - - - - - - - - - - - - - -
KeyDescription
typeEvent type: "ADDED", "MODIFIED", or "DELETED"
objectA ResourceInstance wrapping the resource
raw_objectA plain dict representing the resource
-

Querying Events

-

Stream events for a resource

-

The events() method watches Kubernetes events associated with a resource:

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-
-for event in pod.events(timeout=30):
-    print(f"Reason: {event.object.reason}")
-    print(f"Message: {event.object.message}")
-
- -

Signature:

-
def events(
-    self,
-    name: str = "",
-    label_selector: str = "",
-    field_selector: str = "",
-    resource_version: str = "",
-    timeout: int = 240,
-) -> Generator
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
namestrFilter by event name
label_selectorstrFilter by labels (comma-separated key=value)
field_selectorstrAdditional field filters (merged with auto-generated involvedObject.name filter)
resource_versionstrOnly return events newer than this version
timeoutintWatch timeout in seconds (default: 240)
-
# Example: filter for Warning events with a specific reason
-for event in pod.events(
-    field_selector="type==Warning,reason==BackOff",
-    timeout=10,
-):
-    print(event.object.message)
-
- -

List existing events (non-streaming)

-

Use Event.list() to get a snapshot of existing events without streaming:

-
from ocp_resources.event import Event
-
-# List Warning events from the last 5 minutes
-events = Event.list(
-    client=client,
-    namespace="my-namespace",
-    field_selector="type==Warning",
-)
-for event in events:
-    print(f"{event.reason}: {event.message}")
-
-# List events from the last 10 minutes
-events = Event.list(
-    client=client,
-    namespace="my-namespace",
-    since_seconds=600,
-)
-
- -

Signature:

-
@classmethod
-def Event.list(
-    cls,
-    client: DynamicClient,
-    namespace: str | None = None,
-    field_selector: str | None = None,
-    label_selector: str | None = None,
-    since_seconds: int = 300,
-) -> list
-
- -

Events are returned sorted by timestamp (most recent first).

-

Delete events

-

Clean up events before tests to avoid false positives:

-
from ocp_resources.event import Event
-
-Event.delete_events(
-    client=client,
-    namespace="my-namespace",
-    field_selector="reason=AnEventReason",
-)
-
- -

Advanced Usage

-

Handling resources with duplicate API groups

-

Some resource kinds exist in multiple API groups. Use singular_name to disambiguate:

-

The DNS kind exists in two API groups, so class-generator writes one module per group:

-
    -
  • ocp_resources.dns_config_openshift_io — config.openshift.io/v1
  • -
  • ocp_resources.dns_operator_openshift_io — operator.openshift.io/v1
  • -
-
from ocp_resources.dns_config_openshift_io import DNS
-
-# When a kind exists in multiple API groups, pass singular_name
-for dns in DNS.get(client=client, singular_name="dns"):
-    print(dns.name)
-
- -

Using full_api() for low-level access

-

The full_api() method returns the raw Kubernetes API resource object, giving you direct access to the API:

-
ns = Namespace(client=client, name="my-namespace")
-api = ns.full_api()
-
-# Use the API directly for custom queries
-result = api.get(
-    label_selector="app=nginx",
-    field_selector="status.phase=Active",
-    limit=10,
-)
-
- -

full_api() accepts these keyword arguments:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription
prettyPretty-print output
_continueContinuation token for paginated results
field_selectorFilter by fields
label_selectorFilter by labels
limitMaximum number of results
resource_versionFilter by resource version
timeout_secondsServer-side timeout
watchEnable watch mode
-

Converting resources to YAML

-
ns = Namespace(client=client, name="my-namespace")
-print(ns.to_yaml())
-
- -

Wait for conditions and status

-

For polling-based waiting (e.g., wait until a pod is Running or a condition is True), see Waiting for Resource Conditions and Status.

-

Querying during tests with the fake client

-

You can use all querying methods with the fake client for unit testing:

-
from ocp_resources.resource import get_client
-from ocp_resources.namespace import Namespace
-
-client = get_client(fake=True)
-ns = Namespace(client=client, name="test-ns")
-ns.deploy()
-
-# All query methods work the same way
-assert ns.exists
-assert ns.instance
-assert ns.status == Namespace.Status.ACTIVE
-
-# Listing works too
-for ns in Namespace.get(client=client):
-    print(ns.name)
-
- -

See Testing Without a Cluster Using the Fake Client for details.

-

Troubleshooting

-

ResourceNotFoundError when using ensure_exists=True -The resource does not exist on the cluster. Verify the name, namespace, and that the resource has been created before initializing the wrapper.

-

NotImplementedError: Couldn't find <Kind> in <api_group> -The resource kind is not available on your cluster. This typically means the CRD has not been installed or the API group is incorrect.

-

get() returns no results

-
    -
  • Verify the namespace is correct (omit it for cluster-scoped resources).
  • -
  • Check your label_selector and field_selector syntax — use == for equality (e.g., field_selector="status.phase==Running").
  • -
  • Ensure the client has permissions to list the resource type.
  • -
-

status property raises AttributeError -Not all resources have a status.phase field. Access instance.status directly to inspect what status fields are available for your resource type.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/quickstart.html b/docs/quickstart.html deleted file mode 100644 index e9d0c05d4a..0000000000 --- a/docs/quickstart.html +++ /dev/null @@ -1,652 +0,0 @@ - - - - - - Installing and Creating Your First Resource - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Installing and Creating Your First Resource

-

Get up and running with openshift-python-wrapper so you can programmatically create, query, and delete Kubernetes and OpenShift resources from Python. This guide takes you from installation to your first working resource in under 5 minutes.

-

Prerequisites

-
    -
  • Python 3.11-3.14
  • -
  • Access to a Kubernetes or OpenShift cluster (with a valid kubeconfig)
  • -
  • pip or another Python package manager
  • -
-

Quick Example

-
pip install openshift-python-wrapper
-
- -
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-with Namespace(client=client, name="my-first-namespace") as ns:
-    ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-    print(f"Namespace {ns.name} is active: {ns.exists}")
-# Namespace is automatically deleted when the block exits
-
- -

That's it — the namespace is created when entering the with block and cleaned up when leaving it.

-

Step 1: Install the Package

-
pip install openshift-python-wrapper
-
- -
-

Tip: For isolated environments, use a virtual environment: -bash -python -m venv .venv && source .venv/bin/activate -pip install openshift-python-wrapper

-
-

Step 2: Connect to Your Cluster

-

The get_client() function returns a dynamic client connected to your cluster. By default it reads your kubeconfig from the KUBECONFIG environment variable or ~/.kube/config:

-
from ocp_resources.resource import get_client
-
-client = get_client()
-
- -

You can also point to a specific kubeconfig file:

-
client = get_client(config_file="/path/to/kubeconfig")
-
- -

Or connect with a token and host directly:

-
client = get_client(host="https://api.mycluster.example.com:6443", token="sha256~...")
-
- -
-

Note: For the full set of connection options — including basic auth, in-cluster config, and environment variables — see Connecting to Clusters.

-
-

Step 3: Create a Resource

-

There are two ways to create a resource: explicitly with deploy()/clean_up(), or automatically with a context manager (with statement).

- -

The context manager automatically deletes the resource when the block exits, which prevents leftover resources:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-with Namespace(client=client, name="namespace-example-2") as ns:
-    ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-    assert ns.exists
-# Resource is automatically cleaned up here
-
- -

Option B: Explicit Deploy and Clean Up

-

Use deploy() and clean_up() when you need more control over the lifecycle:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-ns = Namespace(client=client, name="namespace-example-1")
-ns.deploy()
-assert ns.exists
-ns.clean_up()
-
- -
-

Tip: Set teardown=False on the resource to prevent clean_up() from being called when using a context manager. This is useful when you want the resource to persist after the block exits.

-
-

Step 4: Create a Namespaced Resource

-

Most Kubernetes resources (Pods, ConfigMaps, Deployments, etc.) live inside a namespace. These require both name and namespace:

-
from ocp_resources.config_map import ConfigMap
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-with ConfigMap(
-    client=client,
-    name="app-config",
-    namespace="default",
-    data={"app.properties": "debug=true\nport=8080"},
-) as cm:
-    assert cm.exists
-    print(f"ConfigMap {cm.name} created in namespace {cm.namespace}")
-
- -

Step 5: Query Resources

-

Use the get() class method to list resources from the cluster. It returns a generator you can iterate over:

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-for pod in Pod.get(client=client, namespace="default"):
-    print(pod.name)
-
- -

Filter by labels using label_selector:

-
for pod in Pod.get(client=client, label_selector="app=nginx"):
-    node = pod.node
-    print(f"Pod {pod.name} is running on node {node.name}")
-
- -

Check if a specific resource exists:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-ns = Namespace(client=client, name="default")
-if ns.exists:
-    print("Namespace exists!")
-
- -
-

Note: For more on querying, filtering, and watching resources, see Querying and Watching Resources.

-
-

Step 6: Delete a Resource

-

If you didn't use a context manager, call clean_up() to delete the resource:

-
ns = Namespace(client=client, name="my-temp-namespace")
-ns.deploy()
-# ... do work ...
-ns.clean_up()
-
- -

The clean_up() method waits for the resource to be fully deleted by default. Pass wait=False to return immediately:

-
ns.clean_up(wait=False)
-
- -
-

Note: For full details on resource lifecycle management, see Creating and Managing Resources.

-
-

Putting It All Together

-

Here's a complete script that creates a namespace, deploys a ConfigMap inside it, reads it back, and cleans everything up:

-
from ocp_resources.config_map import ConfigMap
-from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-with Namespace(client=client, name="quickstart-demo") as ns:
-    ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-
-    with ConfigMap(
-        client=client,
-        name="demo-config",
-        namespace="quickstart-demo",
-        data={"greeting": "hello from openshift-python-wrapper"},
-    ) as cm:
-        assert cm.exists
-        print(f"Created ConfigMap '{cm.name}' in namespace '{ns.name}'")
-
-        # Query it back
-        for config_map in ConfigMap.get(client=client, namespace="quickstart-demo"):
-            print(f"  Found: {config_map.name}")
-    # ConfigMap cleaned up here
-# Namespace cleaned up here
-
- -

Advanced Usage

-

Creating Resources from YAML

-

You can create any resource from an existing YAML file instead of specifying parameters in Python:

-
from ocp_resources.config_map import ConfigMap
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-cm = ConfigMap(client=client, yaml_file="my-configmap.yaml")
-cm.deploy()
-
- -

Using a Fake Client for Testing

-

You can run your code without a live cluster by using a fake client — useful for unit tests and local development:

-
from ocp_resources.config_map import ConfigMap
-from ocp_resources.resource import get_client
-
-client = get_client(fake=True)
-
-with ConfigMap(
-    client=client,
-    name="test-config",
-    namespace="default",
-    data={"key": "value"},
-) as cm:
-    cm.create()
-    print(f"Created (fake): {cm.name}")
-
- -
-

Note: See Testing Without a Cluster Using the Fake Client for full details on the fake client.

-
-

Schema Validation Before Creating

-

Enable schema validation to catch configuration errors before sending requests to the cluster:

-
from ocp_resources.pod import Pod
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-pod = Pod(
-    client=client,
-    name="validated-pod",
-    namespace="default",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-    schema_validation_enabled=True,  # Validates on create
-)
-pod.deploy()
-
- -

You can also validate manually without creating:

-
pod = Pod(
-    name="check-me",
-    namespace="default",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-)
-pod.validate()  # Raises ValidationError if invalid
-
- -
-

Note: See Validating Resources Against OpenAPI Schemas for more validation options.

-
-

Waiting for Resource Conditions

-

After creating a resource, you can wait for it to reach a specific status or condition:

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import get_client
-
-client = get_client()
-
-with Namespace(client=client, name="wait-demo") as ns:
-    ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-
- -
-

Note: See Waiting for Resource Conditions and Status for advanced waiting patterns.

-
-

Troubleshooting

-

"KUBECONFIG not found" or connection errors

-

Make sure your kubeconfig is in the expected location or explicitly set the path:

-
export KUBECONFIG=/path/to/kubeconfig
-
- -

Or pass it directly:

-
client = get_client(config_file="/path/to/kubeconfig")
-
- -

Resource not found after creation

-

Some resources take time to become available. Use wait_for_status() or wait() to confirm the resource is ready before interacting with it:

-
ns.deploy()
-ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-
- -

Missing name or namespace errors

-
    -
  • Cluster-scoped resources (like Namespace) require only name.
  • -
  • Namespaced resources (like Pod, ConfigMap, Deployment) require both name and namespace.
  • -
-
# Cluster-scoped — only name needed
-Namespace(client=client, name="my-ns")
-
-# Namespaced — both name and namespace required
-ConfigMap(client=client, name="my-cm", namespace="my-ns")
-
- - - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/resource-api.html b/docs/resource-api.html deleted file mode 100644 index 6ae1051068..0000000000 --- a/docs/resource-api.html +++ /dev/null @@ -1,2509 +0,0 @@ - - - - - - Resource and NamespacedResource API - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Resource and NamespacedResource API

-

Complete API reference for the Resource and NamespacedResource base classes, the get_client factory function, and supporting classes (ResourceEditor, ResourceList, NamespacedResourceList, KubeAPIVersion).

-

Import path:

-
from ocp_resources.resource import (
-    Resource,
-    NamespacedResource,
-    ResourceEditor,
-    ResourceList,
-    NamespacedResourceList,
-    get_client,
-)
-
- -
-

get_client

-

Factory function to obtain a Kubernetes DynamicClient.

-
from ocp_resources.resource import get_client
-
-client = get_client(config_file="~/.kube/config")
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
config_filestr \| NoneNonePath to a kubeconfig file. Falls back to KUBECONFIG env var or ~/.kube/config.
config_dictdict[str, Any] \| NoneNoneDict with kubeconfig configuration. Mutually exclusive with config_file.
contextstr \| NoneNoneName of the kubeconfig context to use.
client_configurationkubernetes.client.Configuration \| NoneNonePre-built Kubernetes client configuration object.
persist_configboolTrueWhether to persist config file changes.
temp_file_pathstr \| NoneNonePath to a temporary kubeconfig file.
try_refresh_tokenboolTrueTry to refresh the authentication token.
usernamestr \| NoneNoneUsername for basic auth. Requires password and host.
passwordstr \| NoneNonePassword for basic auth. Requires username and host.
hoststr \| NoneNoneCluster host URL.
verify_sslbool \| NoneNoneWhether to verify SSL certificates.
tokenstr \| NoneNoneBearer token for authentication. Requires host.
fakeboolFalseReturn a FakeDynamicClient for testing without a cluster.
generate_kubeconfigboolFalseSave the resolved kubeconfig to a temp file and attach the path to the client.
-

Returns: DynamicClient | FakeDynamicClient

-
# Default kubeconfig
-client = get_client()
-
-# With explicit config file and context
-client = get_client(config_file="/path/to/kubeconfig", context="my-cluster")
-
-# Token-based auth
-client = get_client(host="https://api.cluster.example.com:6443", token="sha256~abc123")
-
-# Basic auth
-client = get_client(host="https://api.cluster.example.com:6443", username="admin", password="secret")
-
-# Fake client for unit testing
-client = get_client(fake=True)
-
- -
-

Note: If neither config_file nor config_dict is provided, the client falls back to the KUBECONFIG environment variable, then ~/.kube/config, and finally in-cluster configuration.

-
-

See Connecting to Clusters for connection patterns. See Testing Without a Cluster Using the Fake Client for fake=True usage.

-
-

Resource

-
from ocp_resources.resource import Resource
-
- -

Base class for cluster-scoped Kubernetes/OpenShift resources (e.g., Namespace, Node, ClusterRole). Inherits from ResourceConstants. All concrete resource classes inherit from either Resource or NamespacedResource.

-

See Understanding the Resource Class Hierarchy for the inheritance model.

-

Class Attributes

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttributeTypeDefaultDescription
api_groupstr""API group for the resource (e.g., "apps", "batch").
api_versionstr""Full API version string (e.g., "v1", "apps/v1"). Resolved automatically if api_group is set.
singular_namestr""Singular resource name for API calls. Used to disambiguate when multiple resources match the same kind.
timeout_secondsint60Default timeout for API list/watch operations.
-

Constructor

-
Resource(
-    client=client,
-    name="my-resource",
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClient \| NoneNoneKubernetes dynamic client. Will be mandatory in the next major release.
namestr \| NoneNoneResource name. Required unless yaml_file or kind_dict is provided.
teardownboolTrueWhether this resource should be deleted when used as a context manager.
yaml_filestr \| NoneNonePath to a YAML file defining the resource. Mutually exclusive with kind_dict.
delete_timeoutint240Timeout in seconds for delete operations.
dry_runboolFalseIf True, create operations use dry-run mode.
node_selectordict[str, Any] \| NoneNoneNode selector for scheduling.
node_selector_labelsdict[str, str] \| NoneNoneNode selector labels for scheduling.
config_filestr \| NoneNonePath to kubeconfig. Deprecated; pass client instead.
config_dictdict[str, Any] \| NoneNoneKubeconfig dict.
contextstr \| NoneNoneKubeconfig context name. Deprecated; pass client instead.
labeldict[str, str] \| NoneNoneLabels to set on the resource.
annotationsdict[str, str] \| NoneNoneAnnotations to set on the resource.
api_groupstr""Override the class-level api_group.
hash_log_databoolTrueHash sensitive fields (defined by keys_to_hash) in log output.
ensure_existsboolFalseCheck that the resource already exists on the cluster at init time. Raises ResourceNotFoundError if not.
kind_dictdict[Any, Any] \| NoneNoneComplete resource dict. Mutually exclusive with yaml_file. Bypasses to_dict() logic.
wait_for_resourceboolFalseWhen used as a context manager, wait for the resource to exist after creation.
schema_validation_enabledboolFalseEnable automatic OpenAPI schema validation on create() and update_replace().
-

Raises:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
ExceptionCondition
ValueErrorBoth yaml_file and kind_dict are provided.
NotImplementedErrorNeither api_group nor api_version is defined on the class.
MissingRequiredArgumentErrorNone of name, yaml_file, or kind_dict is provided.
ResourceNotFoundErrorensure_exists=True and the resource does not exist.
-
from ocp_resources.namespace import Namespace
-
-# Basic creation
-ns = Namespace(client=client, name="my-namespace")
-
-# From a YAML file
-ns = Namespace(client=client, yaml_file="namespace.yaml")
-
-# From a dict
-ns = Namespace(client=client, kind_dict={"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": "test"}})
-
-# With labels and annotations
-ns = Namespace(client=client, name="my-ns", label={"env": "test"}, annotations={"owner": "team-a"})
-
-# Verify it already exists
-ns = Namespace(client=client, name="default", ensure_exists=True)
-
- -
-

Context Manager Protocol

-

Resource supports the Python context manager protocol for automatic creation and cleanup.

-
from ocp_resources.namespace import Namespace
-
-with Namespace(client=client, name="temp-ns") as ns:
-    # Resource is created on __enter__
-    print(ns.name)
-# Resource is deleted on __exit__ (if teardown=True)
-
- -
- - - - - - - - - - - - - - - - -
MethodDescription
__enter__()Calls deploy(wait=self.wait_for_resource). Registers a SIGINT handler on the main thread to ensure cleanup on Ctrl+C. Returns self.
__exit__(...)Calls clean_up() if teardown=True. Raises ResourceTeardownError if cleanup fails.
-
-

Tip: Set teardown=False to prevent automatic deletion on context exit.

-
-
-

CRUD Methods

-

create

-
def create(
-    self,
-    wait: bool = False,
-    exceptions_dict: dict[type[Exception], list[str]] = ...,
-) -> ResourceInstance | None
-
- -

Create the resource on the cluster.

-
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
waitboolFalseWait for the resource to exist after creation.
exceptions_dictdict[type[Exception], list[str]]DEFAULT_CLUSTER_RETRY_EXCEPTIONS \| PROTOCOL_ERROR_EXCEPTION_DICTExceptions to retry on.
-

Returns: ResourceInstance | None

-

Raises: ValidationError if schema_validation_enabled=True and the resource dict is invalid.

-
ns = Namespace(client=client, name="my-ns")
-ns.create(wait=True)
-
- -

delete

-
def delete(
-    self,
-    wait: bool = False,
-    timeout: int = 240,
-    body: dict[str, Any] | None = None,
-) -> bool
-
- -

Delete the resource from the cluster.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
waitboolFalseWait for the resource to be fully deleted.
timeoutint240Timeout in seconds when wait=True.
bodydict[str, Any] \| NoneNoneOptional delete options body.
-

Returns: bool — True if deleted or not found; False only if wait timed out.

-
ns.delete(wait=True, timeout=120)
-
- -

update

-
def update(self, resource_dict: dict[str, Any]) -> None
-
- -

Patch the resource with a merge-patch (application/merge-patch+json). Only updates the fields present in resource_dict.

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
resource_dictdict[str, Any]Partial resource dictionary with fields to update.
-
-

Note: Schema validation is not applied on update() because patches are partial and would fail full-schema validation.

-
-
ns.update(resource_dict={"metadata": {"labels": {"env": "staging"}}})
-
- -

update_replace

-
def update_replace(self, resource_dict: dict[str, Any]) -> None
-
- -

Replace the full resource. Use this to remove existing fields (unlike update(), which only adds/modifies).

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
resource_dictdict[str, Any]Complete resource dictionary to replace with.
-

Raises: ValidationError if schema_validation_enabled=True and the dict is invalid.

-
full_dict = ns.instance.to_dict()
-full_dict["metadata"]["labels"] = {"new-label": "only"}
-ns.update_replace(resource_dict=full_dict)
-
- -
-

Deploy / Clean Up

-

deploy

-
def deploy(self, wait: bool = False) -> Self
-
- -

Create the resource (respects REUSE_IF_RESOURCE_EXISTS environment variable).

-
- - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
waitboolFalseWait for the resource after creation.
-

Returns: Self

-

See Environment Variables and Configuration for REUSE_IF_RESOURCE_EXISTS details.

-

clean_up

-
def clean_up(self, wait: bool = True, timeout: int | None = None) -> bool
-
- -

Delete the resource (respects SKIP_RESOURCE_TEARDOWN environment variable).

-
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
waitboolTrueWait for deletion to complete.
timeoutint \| NoneNoneTimeout in seconds. Defaults to delete_timeout.
-

Returns: bool — True if deleted successfully.

-

See Environment Variables and Configuration for SKIP_RESOURCE_TEARDOWN details.

-
-

Query Methods and Properties

-

exists

-
@property
-def exists(self) -> ResourceInstance | None
-
- -

Check if the resource exists on the cluster.

-

Returns: ResourceInstance if found, None if not.

-
if ns.exists:
-    print("Namespace exists")
-
- -

instance

-
@property
-def instance(self) -> ResourceInstance
-
- -

Get the live resource instance from the cluster. Retries on transient cluster errors.

-

Returns: ResourceInstance

-

Raises: NotFoundError if the resource does not exist.

-
resource_version = ns.instance.metadata.resourceVersion
-
- -

status

-
@property
-def status(self) -> str
-
- -

Get status.phase from the resource instance.

-

Returns: str — The status phase string (e.g., "Running", "Active", "Pending").

-
print(ns.status)  # "Active"
-
- -

labels

-
@property
-def labels(self) -> ResourceField
-
- -

Get resource labels from metadata.labels.

-

Returns: ResourceField

-
for key, value in ns.labels.items():
-    print(f"{key}={value}")
-
- -

kind

-
@ClassProperty
-def kind(cls) -> str | None
-
- -

Get the resource kind name derived from the class hierarchy. This is a class-level property — accessible on both the class and instances.

-

Returns: str | None

-
from ocp_resources.namespace import Namespace
-
-print(Namespace.kind)  # "Namespace"
-
- -

api

-
@property
-def api(self) -> ResourceInstance
-
- -

Get the resource API object (shortcut for full_api() with no extra kwargs).

-

Returns: ResourceInstance

-

full_api

-
def full_api(self, **kwargs: Any) -> ResourceInstance
-
- -

Get the resource API object with optional filtering kwargs.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Keyword ArgumentDescription
prettyPretty-print output.
_continueContinuation token for list pagination.
field_selectorFilter by field.
label_selectorFilter by label.
limitMaximum number of results.
resource_versionFilter by resource version.
timeout_secondsRequest timeout.
watchEnable watch mode.
-

Returns: ResourceInstance

-
-

Class Method: get

-
@classmethod
-def get(
-    cls,
-    client: DynamicClient | None = None,
-    dyn_client: DynamicClient | None = None,
-    config_file: str = "",
-    singular_name: str = "",
-    exceptions_dict: dict[type[Exception], list[str]] = ...,
-    raw: bool = False,
-    context: str | None = None,
-    *args: Any,
-    **kwargs: Any,
-) -> Generator[Any, None, None]
-
- -

List resources of this kind from the cluster.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClient \| NoneNoneKubernetes client.
dyn_clientDynamicClient \| NoneNoneDeprecated alias for client.
config_filestr""Path to kubeconfig. Deprecated; pass client.
singular_namestr""Singular resource name for disambiguation.
exceptions_dictdictDEFAULT_CLUSTER_RETRY_EXCEPTIONSExceptions to retry.
rawboolFalseIf True, yield raw ResourceInstance objects instead of wrapper instances.
contextstr \| NoneNoneKubeconfig context. Deprecated; pass client.
**kwargsPassed through to the API (e.g., label_selector, field_selector).
-

Returns: Generator of resource instances.

-
-

Note: For Resource (cluster-scoped), yielded objects are constructed with name only. For NamespacedResource, yielded objects include both name and namespace.

-
-
from ocp_resources.namespace import Namespace
-
-for ns in Namespace.get(client=client):
-    print(ns.name)
-
-# With label selector
-for ns in Namespace.get(client=client, label_selector="env=production"):
-    print(ns.name)
-
-# Raw mode
-for raw_ns in Namespace.get(client=client, raw=True):
-    print(raw_ns.metadata.name, raw_ns.status.phase)
-
- -

See Querying and Watching Resources for advanced list/filter patterns.

-
-

Static Method: get_all_cluster_resources

-
@staticmethod
-def get_all_cluster_resources(
-    client: DynamicClient | None = None,
-    config_file: str = "",
-    context: str | None = None,
-    config_dict: dict[str, Any] | None = None,
-    *args: Any,
-    **kwargs: Any,
-) -> Generator[ResourceField, None, None]
-
- -

Yield all resources across all API groups in the cluster.

-
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClient \| NoneNoneKubernetes client.
**kwargsPassed to the API (e.g., label_selector).
-

Yields: ResourceField

-
for resource in Resource.get_all_cluster_resources(client=client, label_selector="app=myapp"):
-    print(f"{resource.kind}: {resource.metadata.name}")
-
- -
-

Wait Methods

-

wait

-
def wait(self, timeout: int = 240, sleep: int = 1) -> None
-
- -

Wait until the resource exists on the cluster.

-
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
timeoutint240Maximum wait time in seconds.
sleepint1Sleep interval between retries.
-

Raises: TimeoutExpiredError if the resource does not exist within the timeout.

-

wait_deleted

-
def wait_deleted(self, timeout: int = 240) -> bool
-
- -

Wait until the resource no longer exists.

-
- - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
timeoutint240Maximum wait time in seconds.
-

Returns: bool — True if deleted, False if timed out.

-

wait_for_status

-
def wait_for_status(
-    self,
-    status: str,
-    timeout: int = 240,
-    stop_status: str | None = None,
-    sleep: int = 1,
-    exceptions_dict: dict[type[Exception], list[str]] = ...,
-) -> None
-
- -

Wait for status.phase to reach the expected value.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
statusstr—Expected status phase string.
timeoutint240Maximum wait time in seconds.
stop_statusstr \| NoneNoneStop and fail immediately if this status is reached. Defaults to Status.FAILED.
sleepint1Sleep interval between retries.
exceptions_dictdictPROTOCOL_ERROR_EXCEPTION_DICT \| DEFAULT_CLUSTER_RETRY_EXCEPTIONSExceptions to retry.
-

Raises: TimeoutExpiredError if the desired status is not reached.

-
from ocp_resources.pod import Pod
-
-pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)
-
- -

wait_for_condition

-
def wait_for_condition(
-    self,
-    condition: str,
-    status: str,
-    timeout: int = 300,
-    sleep_time: int = 1,
-    reason: str | None = None,
-    message: str = "",
-    stop_condition: str | None = None,
-    stop_status: str = "True",
-) -> None
-
- -

Wait for a specific condition to reach the desired state.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
conditionstr—Condition type name (e.g., "Ready", "Available").
statusstr—Expected condition status (e.g., "True", "False").
timeoutint300Maximum wait time in seconds.
sleep_timeint1Interval between retries.
reasonstr \| NoneNoneExpected condition reason. If set, must match exactly.
messagestr""Expected substring within the condition message.
stop_conditionstr \| NoneNoneCondition type that, if matched, stops the wait and fails immediately.
stop_statusstr"True"Status for stop_condition matching.
-

Raises:

-
- - - - - - - - - - - - - - - - -
ExceptionCondition
TimeoutExpiredErrorCondition not met within timeout.
ConditionErrorstop_condition is detected with matching stop_status.
-
ns.wait_for_condition(
-    condition="Ready",
-    status="True",
-    timeout=60,
-)
-
- -

See Waiting for Resource Conditions and Status for more patterns.

-

wait_for_conditions

-
def wait_for_conditions(self) -> None
-
- -

Wait for the resource to have any conditions populated in its status. Uses a 30-second timeout.

-
-

Serialization

-

to_dict

-
def to_dict(self) -> None
-
- -

Populate self.res with the intended dict representation of the resource. Called automatically before create(). Override this in subclasses to add resource-specific fields.

-
-

Note: If kind_dict or yaml_file was provided, to_dict() uses those directly instead of building from individual parameters.

-
-

to_yaml

-
def to_yaml(self) -> str
-
- -

Returns: str — YAML string representation of the resource dict.

-
print(ns.to_yaml())
-
- -
-

Watch / Events

-

watcher

-
def watcher(
-    self,
-    timeout: int,
-    resource_version: str = "",
-) -> Generator[dict[str, Any], None, None]
-
- -

Watch for changes to this specific resource.

-
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
timeoutint—Watch duration in seconds.
resource_versionstr""Only return events after this version. Defaults to the version at resource creation time.
-

Yields: Event dicts with keys type ("ADDED", "MODIFIED", "DELETED"), raw_object, and object.

-
for event in ns.watcher(timeout=30):
-    print(event["type"], event["object"].metadata.name)
-
- -

events

-
def events(
-    self,
-    name: str = "",
-    label_selector: str = "",
-    field_selector: str = "",
-    resource_version: str = "",
-    timeout: int = 240,
-) -> Generator[Any, Any, None]
-
- -

Get Kubernetes events related to this resource.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
namestr""Filter by event name.
label_selectorstr""Comma-separated key=value label filters.
field_selectorstr""Additional field selectors (auto-prefixed with involvedObject.name==<resource_name>).
resource_versionstr""Filter events by resource version.
timeoutint240Timeout in seconds.
-

Yields: Event objects.

-
for event in pod.events(field_selector="type==Warning", timeout=10):
-    print(event.object)
-
- -
-

Validation

-

validate

-
def validate(self) -> None
-
- -

Validate self.res against the OpenAPI schema for this resource kind. Called automatically during create() and update_replace() when schema_validation_enabled=True.

-

Raises: ValidationError if the resource dict is invalid.

-
pod = Pod(client=client, name="test", namespace="default")
-pod.to_dict()
-pod.validate()  # Raises ValidationError on invalid spec
-
- -

validate_dict (classmethod)

-
@classmethod
-def validate_dict(cls, resource_dict: dict[str, Any]) -> None
-
- -

Validate an arbitrary resource dictionary against the schema without creating a resource instance.

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
resource_dictdict[str, Any]Complete resource dictionary.
-

Raises: ValidationError if validation fails.

-
from ocp_resources.pod import Pod
-
-Pod.validate_dict({
-    "apiVersion": "v1",
-    "kind": "Pod",
-    "metadata": {"name": "test"},
-    "spec": {"containers": [{"name": "web", "image": "nginx"}]},
-})
-
- -

See Validating Resources Against OpenAPI Schemas for full validation guide.

-
-

API Request

-

api_request

-
def api_request(
-    self,
-    method: str,
-    action: str,
-    url: str,
-    retry_params: dict[str, int] | None = None,
-    **params: Any,
-) -> dict[str, Any]
-
- -

Send a raw HTTP request to the resource's API endpoint. Used internally for subresource actions (e.g., VirtualMachine start/stop).

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
methodstr—HTTP method ("GET", "PUT", "POST", etc.).
actionstr—Subresource action to append to the URL (e.g., "start", "stop").
urlstr—Base URL of the resource.
retry_paramsdict[str, int] \| NoneNoneDict with timeout and sleep_time keys for retry behavior.
**paramsAdditional params passed to the HTTP request.
-

Returns: dict[str, Any] — Parsed JSON response, or raw response data if not valid JSON.

-
-

Utility Methods

-

retry_cluster_exceptions (static)

-
@staticmethod
-def retry_cluster_exceptions(
-    func: Callable,
-    exceptions_dict: dict[type[Exception], list[str]] = DEFAULT_CLUSTER_RETRY_EXCEPTIONS,
-    timeout: int = 10,
-    sleep_time: int = 1,
-    **kwargs: Any,
-) -> Any
-
- -

Retry a callable on transient cluster errors.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
funcCallable—Function to call.
exceptions_dictdictDEFAULT_CLUSTER_RETRY_EXCEPTIONSMap of exception types to message substrings to match.
timeoutint10Total retry timeout in seconds.
sleep_timeint1Sleep between retries.
**kwargsPassed to func.
-

Returns: The return value of func.

-

Raises: The last exception if timeout is reached.

-

get_condition_message

-
def get_condition_message(
-    self,
-    condition_type: str,
-    condition_status: str = "",
-) -> str
-
- -

Get the message for a specific condition.

-
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
condition_typestr—Condition type name.
condition_statusstr""If set, only return the message when the condition status matches.
-

Returns: str — The condition message, or "" if not found or status doesn't match.

-

hash_resource_dict

-
def hash_resource_dict(self, resource_dict: dict[Any, Any]) -> dict[Any, Any]
-
- -

Replace sensitive fields (defined by keys_to_hash) with "*******" for logging.

-
- - - - - - - - - - - - - - -
ParameterTypeDescription
resource_dictdictThe resource dict to hash.
-

Returns: dict — A copy with sensitive values replaced.

-
-

Tip: Override the keys_to_hash property in subclasses to define which fields to mask.

-
-

keys_to_hash

-
@property
-def keys_to_hash(self) -> list[str]
-
- -

List of key paths to mask in logs. Uses > as separator, [] for list elements.

-

Returns: list[str] — Default: [] (no hashing).

-
# Example override in a Secret subclass:
-@property
-def keys_to_hash(self):
-    return ["data", "stringData"]
-
- -
-

Inner Classes

-

Resource.Status

-

Pre-defined status phase constants (inherited from ResourceConstants).

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConstantValue
Status.SUCCEEDED"Succeeded"
Status.FAILED"Failed"
Status.DELETING"Deleting"
Status.DEPLOYED"Deployed"
Status.PENDING"Pending"
Status.COMPLETED"Completed"
Status.RUNNING"Running"
Status.READY"Ready"
Status.TERMINATING"Terminating"
Status.ERROR"Error"
Status.ACTIVE"Active"
Status.SCHEDULING"Scheduling"
Status.CRASH_LOOPBACK_OFF"CrashLoopBackOff"
Status.IMAGE_PULL_BACK_OFF"ImagePullBackOff"
-
pod.wait_for_status(status=Pod.Status.RUNNING)
-
- -

Resource.Condition

-

Pre-defined condition type and status constants.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConstantValue
Condition.READY"Ready"
Condition.AVAILABLE"Available"
Condition.DEGRADED"Degraded"
Condition.PROGRESSING"Progressing"
Condition.UPGRADEABLE"Upgradeable"
Condition.Status.TRUE"True"
Condition.Status.FALSE"False"
Condition.Status.UNKNOWN"Unknown"
-
ns.wait_for_condition(
-    condition=Resource.Condition.READY,
-    status=Resource.Condition.Status.TRUE,
-)
-
- -

Resource.ApiGroup

-

Pre-defined API group string constants. A selection of commonly used values:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConstantValue
ApiGroup.APPS"apps"
ApiGroup.BATCH"batch"
ApiGroup.NETWORKING_K8S_IO"networking.k8s.io"
ApiGroup.RBAC_AUTHORIZATION_K8S_IO"rbac.authorization.k8s.io"
ApiGroup.STORAGE_K8S_IO"storage.k8s.io"
ApiGroup.CONFIG_OPENSHIFT_IO"config.openshift.io"
ApiGroup.KUBEVIRT_IO"kubevirt.io"
ApiGroup.CDI_KUBEVIRT_IO"cdi.kubevirt.io"
ApiGroup.ROUTE_OPENSHIFT_IO"route.openshift.io"
ApiGroup.MACHINE_OPENSHIFT_IO"machine.openshift.io"
-
-

Note: Over 100 API group constants are available. Use IDE auto-complete to discover all values.

-
-

Resource.ApiVersion

-
- - - - - - - - - - - - - - - - - - - - - - - - -
ConstantValue
ApiVersion.V1"v1"
ApiVersion.V1BETA1"v1beta1"
ApiVersion.V1ALPHA1"v1alpha1"
ApiVersion.V1ALPHA3"v1alpha3"
-
-

NamespacedResource

-
from ocp_resources.resource import NamespacedResource
-
- -

Base class for namespace-scoped resources (e.g., Pod, Deployment, Service, ConfigMap). Extends Resource with namespace awareness.

-

Constructor

-
NamespacedResource(
-    client=client,
-    name="my-pod",
-    namespace="my-namespace",
-)
-
- -

All parameters from Resource are accepted, plus:

-
- - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
namespacestr \| NoneNoneKubernetes namespace. Required unless yaml_file or kind_dict is provided.
-

Raises: MissingRequiredArgumentError if neither (name + namespace) nor yaml_file / kind_dict is provided.

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    client=client,
-    name="nginx",
-    namespace="default",
-    label={"app": "web"},
-)
-
- -

Overridden Methods

-

instance

-
@property
-def instance(self) -> ResourceInstance
-
- -

Get the live resource instance, scoped to self.namespace.

-

Returns: ResourceInstance

-

to_dict

-
def to_dict(self) -> None
-
- -

Populates self.res and sets metadata.namespace. If using yaml_file or kind_dict, reads the namespace from the YAML/dict.

-

Raises: MissingRequiredArgumentError if namespace is still not set after processing.

-

get (classmethod)

-

Behaves like Resource.get(), but yields instances constructed with both name and namespace.

-
from ocp_resources.pod import Pod
-
-for pod in Pod.get(client=client, namespace="default"):
-    print(f"{pod.namespace}/{pod.name}")
-
-# With label selector
-for pod in Pod.get(client=client, namespace="default", label_selector="app=web"):
-    print(pod.name)
-
- -
-

ResourceEditor

-
from ocp_resources.resource import ResourceEditor
-
- -

Temporarily patch resources and automatically restore original values. Designed for test scenarios.

-

Constructor

-
ResourceEditor(
-    patches: dict[Any, Any],
-    action: str = "update",
-    user_backups: dict[Any, Any] | None = None,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
patchesdict—Map of {Resource: patch_dict}.
actionstr"update""update" for merge-patch, "replace" for full replacement.
user_backupsdict \| NoneNonePre-computed backup dicts. If provided, skips automatic backup creation.
-

Context Manager Usage

-
from ocp_resources.resource import ResourceEditor
-from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="my-ns", ensure_exists=True)
-
-with ResourceEditor(patches={ns: {"metadata": {"labels": {"temporary": "true"}}}}) as editor:
-    # Labels are patched
-    assert ns.instance.metadata.labels.temporary == "true"
-# Labels are restored to original values
-
- -

Methods

-
- - - - - - - - - - - - - - - - - - - -
MethodSignatureDescription
updateupdate(backup_resources: bool = False) -> NoneApply patches. If backup_resources=True, back up original values first.
restorerestore() -> NoneRestore all backed-up values.
-

Properties

-
- - - - - - - - - - - - - - - - - - - -
PropertyTypeDescription
backupsdict[Any, Any]Backed-up original values for each patched resource.
patchesdict[Any, Any]The patches dict from the constructor.
-
-

Warning: The DynamicClient used to obtain the resource objects must have sufficient privileges to patch and replace resources.

-
-

See Editing Resources Temporarily with ResourceEditor for detailed patterns.

-
-

ResourceList

-
from ocp_resources.resource import ResourceList
-
- -

Create and manage N copies of a cluster-scoped resource with indexed names.

-

Constructor

-
ResourceList(
-    resource_class: type[Resource],
-    num_resources: int,
-    client: DynamicClient,
-    **kwargs: Any,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
resource_classtype[Resource]The resource class to instantiate.
num_resourcesintNumber of resource copies to create.
clientDynamicClientKubernetes client.
**kwargsPassed to each resource constructor. Must include name (used as base name).
-

Resources are named {name}-1, {name}-2, ..., {name}-N.

-
from ocp_resources.namespace import Namespace
-from ocp_resources.resource import ResourceList
-
-namespaces = ResourceList(
-    resource_class=Namespace,
-    num_resources=3,
-    client=client,
-    name="test-ns",
-)
-
-with namespaces:
-    # Creates test-ns-1, test-ns-2, test-ns-3
-    for ns in namespaces:
-        print(ns.name)
-# All three namespaces are deleted
-
- -

Methods (inherited from BaseResourceList)

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MethodSignatureReturnsDescription
deploydeploy(wait=False)list[Resource]Deploy all resources.
clean_upclean_up(wait=True)boolDelete all resources in reverse order.
__len____len__()intNumber of resources.
__getitem____getitem__(index)ResourceAccess by index.
__iter____iter__()GeneratorIterate over resources.
-
-

NamespacedResourceList

-
from ocp_resources.resource import NamespacedResourceList
-
- -

Create one instance of a namespaced resource in each namespace from a ResourceList.

-

Constructor

-
NamespacedResourceList(
-    resource_class: type[NamespacedResource],
-    namespaces: ResourceList,
-    client: DynamicClient,
-    **kwargs: Any,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
resource_classtype[NamespacedResource]The namespaced resource class (e.g., Pod).
namespacesResourceListA ResourceList of Namespace resources.
clientDynamicClientKubernetes client.
**kwargsPassed to each resource constructor. Must include name.
-

Raises: TypeError if any resource in namespaces is not a Namespace.

-
from ocp_resources.namespace import Namespace
-from ocp_resources.pod import Pod
-from ocp_resources.resource import ResourceList, NamespacedResourceList
-
-namespaces = ResourceList(resource_class=Namespace, num_resources=2, client=client, name="test-ns")
-
-pods = NamespacedResourceList(
-    resource_class=Pod,
-    namespaces=namespaces,
-    client=client,
-    name="nginx",
-)
-
-with namespaces:
-    with pods:
-        # Creates nginx in test-ns-1 and test-ns-2
-        for pod in pods:
-            print(f"{pod.namespace}/{pod.name}")
-
- -

See Managing Bulk Resources with ResourceList for full usage patterns.

-
-

KubeAPIVersion

-
from ocp_resources.resource import KubeAPIVersion
-
- -

Implements Kubernetes API versioning with comparison operators. Extends packaging.version.Version.

-

Constructor

-
KubeAPIVersion(vstring: str)
-
- -
- - - - - - - - - - - - - - -
ParameterTypeDescription
vstringstrVersion string (e.g., "v1", "v1beta1", "v1alpha2").
-

Raises: ValueError if the version string does not conform to Kubernetes versioning.

-

Ordering

-

Versions are ordered: v1alpha1 < v1alpha2 < v1beta1 < v1beta2 < v1 < v2.

-
assert KubeAPIVersion("v1") > KubeAPIVersion("v1beta1")
-assert KubeAPIVersion("v1beta1") > KubeAPIVersion("v1alpha1")
-assert KubeAPIVersion("v1") == KubeAPIVersion("v1")
-
- -
-

Exceptions

-

All exceptions are importable from ocp_resources.exceptions.

-
from ocp_resources.exceptions import (
-    MissingRequiredArgumentError,
-    ResourceTeardownError,
-    ValidationError,
-    ConditionError,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ExceptionRaised ByDescription
MissingRequiredArgumentErrorResource.__init__, NamespacedResource.__init__, NamespacedResource._base_bodyRequired parameters (name, namespace) not provided.
ResourceTeardownErrorResource.__exit__clean_up() returned False during context manager exit.
ValidationErrorvalidate(), validate_dict(), create(), update_replace()Resource dict fails OpenAPI schema validation. Has message, path, and schema_error attributes.
ConditionErrorwait_for_condition()A stop_condition was detected during condition waiting.
MissingResourceResErrorResource._base_bodyDeprecated. self.res is empty after _base_body().
-
-

Timeout Constants

-

Available from ocp_resources.utils.constants:

-
from ocp_resources.utils.constants import (
-    TIMEOUT_1SEC,  # 1
-    TIMEOUT_5SEC,  # 5
-    TIMEOUT_10SEC,  # 10
-    TIMEOUT_30SEC,  # 30
-    TIMEOUT_1MINUTE,  # 60
-    TIMEOUT_2MINUTES,  # 120
-    TIMEOUT_4MINUTES,  # 240
-    TIMEOUT_10MINUTES,  # 600
-)
-
- -
-

Module-Level Functions

-

replace_key_with_hashed_value

-
from ocp_resources.resource import replace_key_with_hashed_value
-
-replace_key_with_hashed_value(resource_dict: dict, key_name: str) -> dict
-
- -

Recursively replace the value at a key path with "*******". Supports dict paths (a>b>c) and list paths (a>b[]>c).

-
- - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
resource_dictdictThe nested dictionary.
key_namestrKey path using > as separator, [] for list elements.
-

Returns: dict — A deep copy with the value replaced.

-
data = {"spec": {"secret": {"token": "sensitive"}}}
-hashed = replace_key_with_hashed_value(data, "spec>secret>token")
-# {"spec": {"secret": {"token": "*******"}}}
-
-# With list path
-data = {"spec": {"containers": [{"env": [{"value": "secret"}]}]}}
-hashed = replace_key_with_hashed_value(data, "spec>containers[]>env[]>value")
-
- - - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/resource-class-hierarchy.html b/docs/resource-class-hierarchy.html deleted file mode 100644 index 1e17c7e5c3..0000000000 --- a/docs/resource-class-hierarchy.html +++ /dev/null @@ -1,671 +0,0 @@ - - - - - - Understanding the Resource Class Hierarchy - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Understanding the Resource Class Hierarchy

-

Every Kubernetes and OpenShift object you interact with through openshift-python-wrapper — whether it's a Pod, a Namespace, or a VirtualMachine — is a Python class that inherits from a small set of base classes. Understanding how these classes relate to each other helps you know what capabilities any resource has, why some resources require a namespace argument and others don't, and how new resource types get added to the library.

-

This page explains the architecture so you can use existing resources effectively and extend the library when you need to work with a new custom resource.

-
-

The Big Picture

-

The class hierarchy has three conceptual layers:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
LayerClassesPurpose
ConstantsResourceConstantsProvides shared status strings, condition names, and type constants used across all resources
Base classesResource, NamespacedResourceDefine all CRUD operations, lifecycle management, waiting, validation, and API discovery logic
Concrete resource classesPod, Namespace, Deployment, VirtualMachine, and 200+ othersRepresent specific Kubernetes/OpenShift resource kinds, adding kind-specific parameters and behavior
-

The inheritance flows like this:

-
ResourceConstants
-  └── Resource                  ← cluster-scoped resources (Node, Namespace, StorageClass, ClusterRole, …)
-        └── NamespacedResource  ← namespace-scoped resources (Pod, Deployment, ConfigMap, Secret, Route, …)
-
- -

Every concrete resource class inherits from either Resource (for cluster-scoped resources) or NamespacedResource (for namespace-scoped resources). This single design decision controls whether the class requires a namespace argument and how it builds API requests.

-
-

Key Concepts

-

ResourceConstants: Shared Vocabulary

-

At the root of the hierarchy sits ResourceConstants, which defines inner classes for common values:

-
    -
  • Status — Strings like RUNNING, SUCCEEDED, FAILED, PENDING, ACTIVE
  • -
  • Condition — Condition types like READY, AVAILABLE, DEGRADED and their status values (TRUE, FALSE, UNKNOWN)
  • -
  • Type — Service types like ClusterIP, NodePort, LoadBalancer
  • -
-

Because Resource inherits from ResourceConstants, every resource class in the library can reference these constants:

-
from ocp_resources.namespace import Namespace
-
-ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)
-
- -

Concrete classes can extend these constants with kind-specific values. For example, VirtualMachine adds statuses like MIGRATING, STOPPED, and PROVISIONING to the base Status class.

-

Resource: The Foundation for Cluster-Scoped Resources

-

Resource is the main base class. It provides everything needed to manage a Kubernetes resource:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CapabilityMethods / Properties
CRUD operationscreate(), delete(), update(), update_replace()
Lifecycle managementdeploy(), clean_up(), context manager (with statement)
Queryingexists, instance, status, labels
Waitingwait(), wait_deleted(), wait_for_status(), wait_for_condition()
Listingget() class method — yields resource objects matching filters
Validationvalidate(), validate_dict()
Serializationto_dict(), to_yaml()
API discoveryAutomatic api_version resolution from the cluster when only api_group is set
-

Resources that exist at the cluster level — not inside any namespace — inherit directly from Resource. Examples include Namespace, Node, StorageClass, ClusterRole, and CustomResourceDefinition.

-
from ocp_resources.namespace import Namespace
-
-# No namespace argument needed — Namespace is cluster-scoped
-ns = Namespace(client=client, name="my-namespace")
-
- -

Automatic Kind Detection

-

You never set the kind field manually. The kind property is a class-level property that automatically derives the Kubernetes kind name from the class name using Python's method resolution order (MRO). When you define a class named StorageClass, its kind is automatically "StorageClass".

-

API Version Discovery

-

Resources can specify their API identity in two ways:

-
    -
  1. api_version — A fixed version string (e.g., "v1"), used for core Kubernetes resources
  2. -
  3. api_group — An API group string (e.g., "apps", "kubevirt.io"), where the full apiVersion is discovered dynamically from the cluster
  4. -
-

When only api_group is set, the library queries the cluster at runtime to find the latest supported version for that resource kind. This means resource classes automatically work across cluster versions without code changes.

-
class Namespace(Resource):
-    # Core resource — fixed version, no group
-    api_version: str = Resource.ApiVersion.V1
-
-
-class ClusterRole(Resource):
-    # Grouped resource — version discovered from cluster
-    api_group = Resource.ApiGroup.RBAC_AUTHORIZATION_K8S_IO
-
- -
-

Note: The Resource.ApiGroup and Resource.ApiVersion inner classes provide predefined constants for all known API groups and versions. Using these constants avoids typos and makes your code self-documenting.

-
-

NamespacedResource: Adding Namespace Awareness

-

NamespacedResource extends Resource with one critical addition: namespace handling. It requires a namespace argument during construction and injects the namespace into all API calls.

-

The differences from Resource are focused but important:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BehaviorResourceNamespacedResource
namespace required?NoYes (unless using yaml_file or kind_dict)
to_dict() outputNo namespace in metadataAdds metadata.namespace
get() yieldsObjects with name onlyObjects with both name and namespace
instance propertyFetches by name onlyFetches by name and namespace
-
from ocp_resources.pod import Pod
-
-# Namespace is required for namespaced resources
-pod = Pod(client=client, name="my-pod", namespace="default", containers=[{"name": "app", "image": "nginx"}])
-
- -

Concrete Resource Classes: Where Specifics Live

-

Concrete classes add three things on top of the base classes:

-
    -
  1. API identity — Setting api_group or api_version to identify which Kubernetes API to call
  2. -
  3. Constructor parameters — Typed arguments for the resource's spec fields (like containers for Pod, replicas for Deployment)
  4. -
  5. Custom to_dict() method — Builds the Kubernetes resource dictionary from constructor arguments
  6. -
-

Here is how a typical generated class is structured:

-
class Deployment(NamespacedResource):
-    # 1. API identity
-    api_group: str = NamespacedResource.ApiGroup.APPS
-
-    def __init__(self, replicas=None, selector=None, template=None, **kwargs):
-        # 2. Pass common args to base class, store kind-specific args
-        super().__init__(**kwargs)
-        self.replicas = replicas
-        self.selector = selector
-        self.template = template
-
-    def to_dict(self):
-        # 3. Build the resource dictionary
-        super().to_dict()
-        if not self.kind_dict and not self.yaml_file:
-            self.res["spec"] = {}
-            _spec = self.res["spec"]
-            _spec["selector"] = self.selector
-            _spec["template"] = self.template
-            if self.replicas is not None:
-                _spec["replicas"] = self.replicas
-
- -
-

Tip: You can always bypass the typed constructor entirely by passing yaml_file or kind_dict to any resource class. When you do, the to_dict() logic is skipped and the resource is created from your raw definition instead.

-
-

Adding Kind-Specific Behavior

-

Many concrete classes go beyond what the generator produces by adding custom methods and properties. For example:

-
    -
  • Pod adds execute() for running commands, log() for reading logs, and a node property
  • -
  • Deployment adds scale_replicas() and wait_for_replicas()
  • -
  • Secret overrides keys_to_hash to ensure sensitive data is masked in logs
  • -
-

These additions are preserved across regeneration because the class generator recognizes the # End of generated code marker and keeps any code written below it.

-

How Generated Classes Are Created

-

Most concrete resource classes in the library are code-generated from the cluster's OpenAPI schema using the class-generator tool. The generator:

-
    -
  1. Reads the resource definition from the cluster's OpenAPI schema
  2. -
  3. Determines whether the resource is namespaced (→ NamespacedResource) or cluster-scoped (→ Resource)
  4. -
  5. Extracts spec fields with their types and descriptions
  6. -
  7. Renders a Python class from a Jinja2 template
  8. -
  9. Preserves any hand-written code below the # End of generated code marker
  10. -
-

This means the hierarchy is not just an architectural choice — it is enforced by the code generation pipeline. Every generated class correctly inherits from the appropriate base class based on the resource's actual scope in Kubernetes.

-

See Generating New Resource Classes with class-generator for details on generating classes for new resource types.

-
-

How It Affects You

-

Understanding the hierarchy helps you in several practical ways:

-

Knowing What Methods Are Available

-

Every resource — regardless of kind — inherits the full set of CRUD, waiting, and lifecycle methods from Resource. You don't need to check whether a particular resource supports wait_for_condition() or context managers; they all do.

-
# Works for any resource type
-with SomeResource(client=client, name="example", **specific_args) as res:
-    res.wait_for_condition(condition="Ready", status="True")
-
- -

See Resource and NamespacedResource API for the complete method reference.

-

Understanding Constructor Requirements

-

The base class determines what arguments are mandatory:

-
- - - - - - - - - - - - - - - - - - - - - - - - -
If you're using...Required arguments
Resource subclassclient, name
NamespacedResource subclassclient, name, namespace
Any class with yaml_fileclient, yaml_file
Any class with kind_dictclient, kind_dict
-

See Creating and Managing Resources for full examples of all creation methods.

-

Using Status and Condition Constants

-

The Status and Condition constants inherited from ResourceConstants are available on every resource class. Concrete classes may extend them:

-
from ocp_resources.virtual_machine import VirtualMachine
-
-# Base constants work on all resources
-vm.wait_for_status(status=VirtualMachine.Status.RUNNING)
-
-# Kind-specific constants are added by the concrete class
-vm.wait_for_status(status=VirtualMachine.Status.STOPPED)
-
- -

Extending the Library

-

If you need to work with a CRD that isn't included in the library, you have two options:

-
    -
  1. Use the class generator to scaffold a new class automatically — see Generating New Resource Classes with class-generator
  2. -
  3. Write a class manually by inheriting from Resource or NamespacedResource and setting api_group or api_version
  4. -
-

A minimal hand-written resource class looks like this:

-
from ocp_resources.resource import NamespacedResource
-
-
-class MyCustomResource(NamespacedResource):
-    api_group = "example.com"
-    # That's it — you get full CRUD, waiting, and lifecycle support
-
- -
-

Warning: Your class name must match the Kubernetes kind exactly (in PascalCase). The kind property is derived from the class name automatically — a class named MyCustomResource will have kind = "MyCustomResource".

-
-
- - - - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/schema-validation-internals.html b/docs/schema-validation-internals.html deleted file mode 100644 index 1a3cc71f04..0000000000 --- a/docs/schema-validation-internals.html +++ /dev/null @@ -1,788 +0,0 @@ - - - - - - Schema Validation and Code Generation Architecture - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Schema Validation and Code Generation Architecture

-

The openshift-python-wrapper uses OpenAPI schemas as the single source of truth for two critical capabilities: validating resource definitions before they reach the cluster, and generating Python wrapper classes for any Kubernetes or OpenShift resource type. Understanding this architecture helps you diagnose validation errors, generate classes for custom resources, and keep your schemas up to date as clusters evolve.

-

The Big Picture

-

OpenAPI schemas flow through the system in two directions: they are fetched from a live cluster and stored locally, then consumed by the validation engine at runtime and by the code generator at development time.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ComponentLocationPurpose
Schema Fetcherclass_generator/core/schema.pyConnects to a cluster, downloads OpenAPI v3 schemas, and writes them to disk
Resource Mappingsclass_generator/schema/__resources-mappings.json.gzCompressed archive mapping each resource kind (lowercase) to its schema(s)
Definitions Fileclass_generator/schema/_definitions.jsonJSON file containing detailed schema definitions with $ref targets for nested types
Schema Validatorocp_resources/utils/schema_validator.pyRuntime engine that loads schemas, resolves $ref references, and validates resource dicts
Code Generatorclass_generator/core/generator.pyReads schemas and generates Python class files from a Jinja2 template
Explain Parserclass_generator/parsers/explain_parser.pyParses the resource mapping to extract fields, types, and group-version-kind metadata
Cluster Version Trackerclass_generator/schema/__cluster_version__.txtTracks the cluster version that last produced the schema, controlling update strategy
-

Data Flow: Schema Fetch and Storage

-
    -
  1. Detect client binary — The system finds oc or falls back to kubectl via get_client_binary().
  2. -
  3. Check cluster version — check_and_update_cluster_version() compares the connected cluster's version against the stored version in __cluster_version__.txt.
  4. -
  5. Determine update strategy — If the cluster is the same version or newer, all schemas are fetched and existing entries are updated. If older, only missing resources are fetched and existing schemas are preserved.
  6. -
  7. Fetch OpenAPI v3 index — GET /openapi/v3 returns an index of all API group paths (e.g., api/v1, apis/apps/v1).
  8. -
  9. Fetch schemas in parallel — fetch_all_api_schemas() downloads schemas from each API path using a thread pool (up to 10 workers).
  10. -
  11. Build namespacing dictionary — build_namespacing_dict() queries api-resources to determine which kinds are namespaced vs. cluster-scoped.
  12. -
  13. Process definitions — process_schema_definitions() extracts x-kubernetes-group-version-kind metadata, builds schema entries, and merges them into the resource mappings. Existing resources are never deleted — only added or updated.
  14. -
  15. Supplement with oc explain — Missing field descriptions, required-field markers, and $ref definitions are filled in by running oc explain commands in parallel.
  16. -
  17. Write to disk — The resource mappings are saved as a gzip-compressed JSON archive (__resources-mappings.json.gz), and definitions are written to _definitions.json.
  18. -
-

Data Flow: Validation at Runtime

-
    -
  1. Load on first use — SchemaValidator.load_mappings_data() decompresses and loads the mappings archive and definitions file into class-level caches.
  2. -
  3. Look up by kind — SchemaValidator.load_schema(kind, api_group) finds the schema for a resource kind (case-insensitive). When multiple API groups define the same kind (e.g., Ingress in networking.k8s.io and config.openshift.io), the api_group parameter disambiguates.
  4. -
  5. Resolve $ref references — The validator recursively resolves all $ref pointers against the definitions data, producing a self-contained schema.
  6. -
  7. Cache resolved schemas — Resolved schemas are stored in _schema_cache keyed by api_group:kind, so subsequent validations are fast.
  8. -
  9. Validate with jsonschema — jsonschema.validate() checks the resource dictionary against the resolved schema.
  10. -
  11. Format errors — If validation fails, format_validation_error() produces a human-readable message including the field path, error details, and schema context.
  12. -
-

Data Flow: Code Generation

-
    -
  1. Read resource mappings — parse_explain() loads the mapping file and finds all schema entries for the requested kind.
  2. -
  3. Select latest API version — When multiple versions exist within an API group, the latest version is selected using a priority ranking (v2 > v1 > v1beta2 > v1beta1 > v1alpha2 > v1alpha1).
  4. -
  5. Extract fields and types — The type_parser module converts OpenAPI types to Python type annotations and builds parameter dictionaries for the class constructor.
  6. -
  7. Render Jinja2 template — render_jinja_template() processes class_generator_template.j2 with the extracted data, producing a complete Python class.
  8. -
  9. Preserve user code — If the target file already exists, parse_user_code_from_file() extracts any user-added code below the # End of generated code marker and re-inserts it after regeneration.
  10. -
  11. Format and write — write_and_format_rendered() writes the file, then formats it with prek (the repository's own pre-commit hooks, unmodified). prek runs a second time to reach a fixpoint; if it still fails, the kind fails loudly instead of leaving an unformatted file. No other formatter is invoked, so a file that passes locally also passes CI.
  12. -
-

Key Concepts

-

The Resource Mappings File

-

The resource mappings file (__resources-mappings.json.gz) is the central schema store. It maps lowercase kind names to arrays of schema objects:

-
{
-  "pod": [
-    {
-      "description": "Pod is a collection of containers...",
-      "properties": { ... },
-      "required": [],
-      "type": "object",
-      "x-kubernetes-group-version-kind": [
-        { "group": "", "kind": "Pod", "version": "v1" }
-      ],
-      "namespaced": true
-    }
-  ],
-  "ingress": [
-    { "x-kubernetes-group-version-kind": [{ "group": "networking.k8s.io", ... }], ... },
-    { "x-kubernetes-group-version-kind": [{ "group": "config.openshift.io", ... }], ... }
-  ]
-}
-
- -

Resources with the same kind but different API groups (like Ingress above) are stored as separate entries in the array. This structure allows the validator and generator to handle API group disambiguation correctly.

-
-

Note: The mappings file is compressed with gzip to reduce repository size. The archive_utils module handles transparent compression and decompression via save_json_archive() and load_json_archive().

-
-

The Definitions File

-

The definitions file (_definitions.json) contains detailed schema definitions keyed by group/version/Kind paths (e.g., apps/v1/Deployment, v1/Pod). It also stores referenced sub-schemas like io.k8s.api.core.v1.PodSpec that are targets of $ref pointers. During validation, the SchemaValidator uses this file as the reference store for resolving nested types.

-

SchemaValidator

-

SchemaValidator is a class with only class-level methods and caches — you never need to instantiate it. It serves as the shared validation engine used by both Resource.validate() and Resource.validate_dict().

-
from ocp_resources.utils.schema_validator import SchemaValidator
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MethodSignatureDescription
load_mappings_data(skip_cache: bool = False) -> boolLoads the mappings archive and definitions file. Returns True on success.
get_mappings_data(skip_cache: bool = False) -> dict[str, Any] \| NoneReturns loaded mappings data, loading first if needed.
get_definitions_data() -> dict[str, Any] \| NoneReturns loaded definitions data, loading first if needed.
load_schema(kind: str, api_group: str \| None = None) -> dict[str, Any] \| NoneLoads and resolves a complete schema for a kind. Handles API group disambiguation and caching.
validate(resource_dict: dict[str, Any], kind: str, api_group: str \| None = None) -> NoneValidates a resource dict. Raises jsonschema.ValidationError on failure.
format_validation_error(error, kind, name, api_group=None) -> strFormats a validation error into a user-friendly message with field path and context.
clear_cache() -> NoneClears the resolved schema cache (not the raw mappings/definitions data).
-
-

Tip: The load_schema method resolves $ref references recursively. For core Kubernetes types like ObjectMeta or TypeMeta that may be missing from definitions, it falls back to a permissive {"type": "object", "additionalProperties": true} schema rather than failing outright.

-
-

Resource Validation Methods

-

The Resource base class exposes two validation methods that delegate to SchemaValidator:

-
from ocp_resources.resource import Resource
-
-# Instance-level validation — validates self.res
-resource.validate()
-
-# Class-level validation — validates any dict against the class's schema
-MyResource.validate_dict(resource_dict)
-
- -

Both raise ocp_resources.exceptions.ValidationError (not the raw jsonschema.ValidationError) with formatted error messages that include the resource identifier, field path, and details.

-

Auto-validation can be enabled per-instance by passing schema_validation_enabled=True to the constructor:

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    name="my-pod",
-    namespace="default",
-    containers=[{"name": "app", "image": "nginx"}],
-    client=client,
-    schema_validation_enabled=True,
-)
-# validation runs automatically before create() and update_replace()
-pod.deploy()
-
- -

When auto-validation is enabled:

-
    -
  • create() calls self.validate() after to_dict() builds the resource dictionary
  • -
  • update_replace() calls validate_dict() on the replacement resource dictionary
  • -
  • update() (patch operations) does not trigger validation because patches are partial documents that may not pass full schema validation
  • -
-

Schema Update Strategies

-

The update_kind_schema() function employs a version-aware update strategy:

-
- - - - - - - - - - - - - - - - -
Cluster VersionBehavior
Same or newer than last recordedFetches all API schemas, updates existing resources, supplements with oc explain data
Older than last recordedIdentifies only missing resources via identify_missing_resources(), fetches only relevant API paths, does not modify existing schemas
-

This strategy prevents an older cluster from overwriting schemas that contain richer data from a newer cluster.

-
-

Warning: If you connect to an older cluster and need to update a specific CRD's schema (for example, after installing a new operator), use update_single_resource_schema(kind) or the CLI flag --update-schema-for <Kind>. This bypasses the version guard for that one resource.

-
-

Single-Resource Schema Updates

-

The update_single_resource_schema() function fetches the schema for exactly one resource kind without affecting other resources in the mapping:

-
from class_generator.core.schema import update_single_resource_schema
-
-update_single_resource_schema(kind="LlamaStackDistribution")
-
- -
- - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
kindstrThe resource Kind (case-sensitive, e.g., "LlamaStackDistribution")
clientstr \| NoneClient binary path. Auto-detected if None.
-

Raises:

-
    -
  • ResourceNotFoundError — if the kind is not found on the cluster
  • -
  • RuntimeError — if schema fetching fails
  • -
-

Code Generation Pipeline

-

The class_generator() function orchestrates the full generation pipeline:

-
from class_generator.core.generator import class_generator
-
-generated_files = class_generator(kind="Deployment", overwrite=True)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
kindstr(required)Kubernetes resource kind
overwriteboolFalseOverwrite existing files
dry_runboolFalsePrint output without writing
output_filestr""Specific output file path
output_dirstr""Output directory (defaults to ocp_resources)
add_testsboolFalseGenerate test files in class_generator/tests/manifests/
-

Returns: list[str] — paths of generated files.

-

Raises:

-
    -
  • RuntimeError — if the kind is not found in schema mappings
  • -
  • ValueError — if the generated filename contains invalid patterns
  • -
-

When a kind exists in multiple API groups (e.g., Ingress in both networking.k8s.io and config.openshift.io), the generator produces separate files with a group-based suffix (e.g., ingress_networking_k8s_io.py, ingress_config_openshift_io.py).

-

Generated Class Structure

-

Every generated file follows a consistent structure produced by the Jinja2 template:

-
# Generated using https://github.com/RedHatQE/openshift-python-wrapper/blob/main/class_generator/README.md
-
-from typing import Any
-from ocp_resources.resource import NamespacedResource
-
-class Pod(NamespacedResource):
-    """Pod is a collection of containers that can run on a host."""
-
-    api_version: str = NamespacedResource.ApiVersion.V1
-
-    def __init__(self, containers: list[Any] | None = None, ..., **kwargs: Any) -> None:
-        """Args:
-            containers (list[Any]): List of containers belonging to the pod.
-            ...
-        """
-        super().__init__(**kwargs)
-        self.containers = containers
-        ...
-
-    def to_dict(self) -> None:
-        super().to_dict()
-        if not self.kind_dict and not self.yaml_file:
-            # Required fields raise MissingRequiredArgumentError
-            # Optional fields added only if not None
-            ...
-    # End of generated code
-
- -

User-added code placed after the # End of generated code marker is preserved across regenerations.

-

$ref Resolution

-

OpenAPI schemas extensively use $ref pointers to reference shared type definitions (e.g., "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"). The SchemaValidator._resolve_refs() method handles this by:

-
    -
  1. Parsing the reference path to extract the definition name
  2. -
  3. Looking up the definition in _definitions_data using multiple key formats (full dotted path, short name, or version/Kind format)
  4. -
  5. Recursively resolving any $ref pointers within the resolved definition
  6. -
  7. Falling back to a permissive object schema for well-known core types that may be missing
  8. -
-

Schema Supplementation via oc explain

-

The raw OpenAPI v3 schemas from the cluster sometimes lack field descriptions and required-field markers. The system supplements these gaps by:

-
    -
  • Running oc explain <resource>.<field> commands in parallel for critical resource types (Pod, Deployment, Service, etc.)
  • -
  • Parsing the explain output to extract field descriptions, types, and required-field markers
  • -
  • Merging descriptions into existing schema properties where they are missing
  • -
  • Detecting missing $ref targets and fetching their definitions via oc explain --recursive
  • -
-

This supplementation only runs during a full update (same or newer cluster version), not when only fetching missing resources.

-

How It Affects You

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
What you doWhat happens under the hood
Call resource.validate()SchemaValidator loads schemas from the compressed archive, resolves $ref references, validates with jsonschema, and raises ValidationError with a formatted message
Pass schema_validation_enabled=Truecreate() and update_replace() automatically validate before sending to the API server
Run class-generator --update-schemaFull schema fetch from cluster, version check, parallel API downloads, oc explain supplementation, and compressed archive write
Run class-generator -k PodReads the mapping for pod, parses fields/types, renders Jinja2 template, preserves user code, writes and formats the Python file
Run class-generator --update-schema-for MyResourceSingle-resource fetch that bypasses the cluster version guard
Run class-generator --regenerate-allDiscovers all generated files (by marker comment), regenerates each in parallel, preserving user code
Validation reports "No schema found"The resource kind is not in the mappings archive — run --update-schema to fetch it
Multiple files generated for one kindThe kind exists in multiple API groups — each group gets its own file with a suffix
- - - - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/search-index.json b/docs/search-index.json deleted file mode 100644 index 6faee843a7..0000000000 --- a/docs/search-index.json +++ /dev/null @@ -1 +0,0 @@ -[{"slug": "quickstart", "title": "Installing and Creating Your First Resource", "content": "# Installing and Creating Your First Resource\n\nGet up and running with openshift-python-wrapper so you can programmatically create, query, and delete Kubernetes and OpenShift resources from Python. This guide takes you from installation to your first working resource in under 5 minutes.\n\n## Prerequisites\n\n- Python 3.11-3.14\n- Access to a Kubernetes or OpenShift cluster (with a valid kubeconfig)\n- `pip` or another Python package manager\n\n## Quick Example\n\n```bash\npip install openshift-python-wrapper\n```\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nwith Namespace(client=client, name=\"my-first-namespace\") as ns:\n ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n print(f\"Namespace {ns.name} is active: {ns.exists}\")\n# Namespace is automatically deleted when the block exits\n```\n\nThat's it \u2014 the namespace is created when entering the `with` block and cleaned up when leaving it.\n\n## Step 1: Install the Package\n\n```bash\npip install openshift-python-wrapper\n```\n\n> **Tip:** For isolated environments, use a virtual environment:\n> ```bash\n> python -m venv .venv && source .venv/bin/activate\n> pip install openshift-python-wrapper\n> ```\n\n## Step 2: Connect to Your Cluster\n\nThe `get_client()` function returns a dynamic client connected to your cluster. By default it reads your kubeconfig from the `KUBECONFIG` environment variable or `~/.kube/config`:\n\n```python\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n```\n\nYou can also point to a specific kubeconfig file:\n\n```python\nclient = get_client(config_file=\"/path/to/kubeconfig\")\n```\n\nOr connect with a token and host directly:\n\n```python\nclient = get_client(host=\"https://api.mycluster.example.com:6443\", token=\"sha256~...\")\n```\n\n> **Note:** For the full set of connection options \u2014 including basic auth, in-cluster config, and environment variables \u2014 see [Connecting to Clusters](connecting-to-clusters.html).\n\n## Step 3: Create a Resource\n\nThere are two ways to create a resource: explicitly with `deploy()`/`clean_up()`, or automatically with a context manager (`with` statement).\n\n### Option A: Context Manager (Recommended)\n\nThe context manager automatically deletes the resource when the block exits, which prevents leftover resources:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nwith Namespace(client=client, name=\"namespace-example-2\") as ns:\n ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n assert ns.exists\n# Resource is automatically cleaned up here\n```\n\n### Option B: Explicit Deploy and Clean Up\n\nUse `deploy()` and `clean_up()` when you need more control over the lifecycle:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nns = Namespace(client=client, name=\"namespace-example-1\")\nns.deploy()\nassert ns.exists\nns.clean_up()\n```\n\n> **Tip:** Set `teardown=False` on the resource to prevent `clean_up()` from being called when using a context manager. This is useful when you want the resource to persist after the block exits.\n\n## Step 4: Create a Namespaced Resource\n\nMost Kubernetes resources (Pods, ConfigMaps, Deployments, etc.) live inside a namespace. These require both `name` and `namespace`:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nwith ConfigMap(\n client=client,\n name=\"app-config\",\n namespace=\"default\",\n data={\"app.properties\": \"debug=true\\nport=8080\"},\n) as cm:\n assert cm.exists\n print(f\"ConfigMap {cm.name} created in namespace {cm.namespace}\")\n```\n\n## Step 5: Query Resources\n\nUse the `get()` class method to list resources from the cluster. It returns a generator you can iterate over:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nfor pod in Pod.get(client=client, namespace=\"default\"):\n print(pod.name)\n```\n\nFilter by labels using `label_selector`:\n\n```python\nfor pod in Pod.get(client=client, label_selector=\"app=nginx\"):\n node = pod.node\n print(f\"Pod {pod.name} is running on node {node.name}\")\n```\n\nCheck if a specific resource exists:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nns = Namespace(client=client, name=\"default\")\nif ns.exists:\n print(\"Namespace exists!\")\n```\n\n> **Note:** For more on querying, filtering, and watching resources, see [Querying and Watching Resources](querying-resources.html).\n\n## Step 6: Delete a Resource\n\nIf you didn't use a context manager, call `clean_up()` to delete the resource:\n\n```python\nns = Namespace(client=client, name=\"my-temp-namespace\")\nns.deploy()\n# ... do work ...\nns.clean_up()\n```\n\nThe `clean_up()` method waits for the resource to be fully deleted by default. Pass `wait=False` to return immediately:\n\n```python\nns.clean_up(wait=False)\n```\n\n> **Note:** For full details on resource lifecycle management, see [Creating and Managing Resources](creating-and-managing-resources.html).\n\n## Putting It All Together\n\nHere's a complete script that creates a namespace, deploys a ConfigMap inside it, reads it back, and cleans everything up:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nwith Namespace(client=client, name=\"quickstart-demo\") as ns:\n ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n\n with ConfigMap(\n client=client,\n name=\"demo-config\",\n namespace=\"quickstart-demo\",\n data={\"greeting\": \"hello from openshift-python-wrapper\"},\n ) as cm:\n assert cm.exists\n print(f\"Created ConfigMap '{cm.name}' in namespace '{ns.name}'\")\n\n # Query it back\n for config_map in ConfigMap.get(client=client, namespace=\"quickstart-demo\"):\n print(f\" Found: {config_map.name}\")\n # ConfigMap cleaned up here\n# Namespace cleaned up here\n```\n\n## Advanced Usage\n\n### Creating Resources from YAML\n\nYou can create any resource from an existing YAML file instead of specifying parameters in Python:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\ncm = ConfigMap(client=client, yaml_file=\"my-configmap.yaml\")\ncm.deploy()\n```\n\n### Using a Fake Client for Testing\n\nYou can run your code without a live cluster by using a fake client \u2014 useful for unit tests and local development:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\nfrom ocp_resources.resource import get_client\n\nclient = get_client(fake=True)\n\nwith ConfigMap(\n client=client,\n name=\"test-config\",\n namespace=\"default\",\n data={\"key\": \"value\"},\n) as cm:\n cm.create()\n print(f\"Created (fake): {cm.name}\")\n```\n\n> **Note:** See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for full details on the fake client.\n\n### Schema Validation Before Creating\n\nEnable schema validation to catch configuration errors before sending requests to the cluster:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\npod = Pod(\n client=client,\n name=\"validated-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n schema_validation_enabled=True, # Validates on create\n)\npod.deploy()\n```\n\nYou can also validate manually without creating:\n\n```python\npod = Pod(\n name=\"check-me\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n)\npod.validate() # Raises ValidationError if invalid\n```\n\n> **Note:** See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for more validation options.\n\n### Waiting for Resource Conditions\n\nAfter creating a resource, you can wait for it to reach a specific status or condition:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nwith Namespace(client=client, name=\"wait-demo\") as ns:\n ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n```\n\n> **Note:** See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for advanced waiting patterns.\n\n## Troubleshooting\n\n### \"KUBECONFIG not found\" or connection errors\n\nMake sure your kubeconfig is in the expected location or explicitly set the path:\n\n```bash\nexport KUBECONFIG=/path/to/kubeconfig\n```\n\nOr pass it directly:\n\n```python\nclient = get_client(config_file=\"/path/to/kubeconfig\")\n```\n\n### Resource not found after creation\n\nSome resources take time to become available. Use `wait_for_status()` or `wait()` to confirm the resource is ready before interacting with it:\n\n```python\nns.deploy()\nns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n```\n\n### Missing `name` or `namespace` errors\n\n- Cluster-scoped resources (like `Namespace`) require only `name`.\n- Namespaced resources (like `Pod`, `ConfigMap`, `Deployment`) require both `name` and `namespace`.\n\n```python\n# Cluster-scoped \u2014 only name needed\nNamespace(client=client, name=\"my-ns\")\n\n# Namespaced \u2014 both name and namespace required\nConfigMap(client=client, name=\"my-cm\", namespace=\"my-ns\")\n```\n\n## Related Pages\n\n- [Connecting to Clusters](connecting-to-clusters.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Querying and Watching Resources](querying-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html)\n"}, {"slug": "connecting-to-clusters", "title": "Connecting to Clusters", "content": "# Connecting to Clusters\n\nEstablish authenticated connections to Kubernetes and OpenShift clusters so you can create, query, and manage resources with openshift-python-wrapper.\n\n## Prerequisites\n\n- openshift-python-wrapper installed (see [Installing and Creating Your First Resource](quickstart.html))\n- Access to a Kubernetes or OpenShift cluster (kubeconfig file, token, or in-cluster credentials)\n\n## Quick Example\n\n```python\nfrom ocp_resources.resource import get_client\nfrom ocp_resources.namespace import Namespace\n\n# Connect using default kubeconfig (~/.kube/config or $KUBECONFIG)\nclient = get_client()\n\n# Use the client with any resource\nfor ns in Namespace.get(client=client):\n print(ns.name)\n```\n\n## Creating a Client\n\nAll cluster connections go through `get_client()`, which returns a Kubernetes `DynamicClient`. Pass this client to every resource you create or query.\n\n### Using the Default Kubeconfig\n\nIf you have a kubeconfig at `~/.kube/config` or the `KUBECONFIG` environment variable is set, no arguments are needed:\n\n```python\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n```\n\nThe lookup order is:\n\n1. The `config_file` parameter (if provided)\n2. The `KUBECONFIG` environment variable\n3. `~/.kube/config`\n\n### Using a Specific Kubeconfig File\n\n```python\nclient = get_client(config_file=\"/path/to/my/kubeconfig\")\n```\n\n### Selecting a Context\n\nIf your kubeconfig has multiple contexts, select one by name:\n\n```python\nclient = get_client(config_file=\"/path/to/kubeconfig\", context=\"my-staging-cluster\")\n```\n\n### Authenticating with a Bearer Token\n\nConnect to a cluster API server directly using a host URL and token:\n\n```python\nclient = get_client(\n host=\"https://api.my-cluster.example.com:6443\",\n token=\"sha256~my-bearer-token\",\n)\n```\n\n### Authenticating with Username and Password (Basic Auth)\n\nFor OpenShift clusters that support OAuth-based basic authentication:\n\n```python\nclient = get_client(\n host=\"https://api.my-cluster.example.com:6443\",\n username=\"my-user\",\n password=\"my-password\",\n)\n```\n\n> **Note:** Basic auth requires all three parameters: `host`, `username`, and `password`. This uses OpenShift's OAuth flow internally \u2014 it is not plain HTTP Basic Authentication.\n\n### Using a Kubeconfig Dictionary\n\nPass a kubeconfig as a Python dictionary instead of a file path:\n\n```python\nconfig_dict = {\n \"apiVersion\": \"v1\",\n \"kind\": \"Config\",\n \"clusters\": [{\"name\": \"my-cluster\", \"cluster\": {\"server\": \"https://api.example.com:6443\"}}],\n \"users\": [{\"name\": \"my-user\", \"user\": {\"token\": \"my-token\"}}],\n \"contexts\": [{\"name\": \"my-context\", \"context\": {\"cluster\": \"my-cluster\", \"user\": \"my-user\"}}],\n \"current-context\": \"my-context\",\n}\n\nclient = get_client(config_dict=config_dict)\n```\n\n## Passing the Client to Resources\n\nOnce you have a client, pass it to resource constructors and class methods:\n\n```python\nfrom ocp_resources.resource import get_client\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.pod import Pod\n\nclient = get_client()\n\n# Creating a resource\nns = Namespace(client=client, name=\"my-namespace\")\nns.deploy()\n\n# Querying resources\nfor pod in Pod.get(client=client, namespace=\"my-namespace\"):\n print(pod.name)\n\n# Using context managers\nwith Namespace(client=client, name=\"temp-namespace\") as ns:\n ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n```\n\n> **Warning:** The `client` parameter will become mandatory in the next major release. Always pass it explicitly. If omitted, the library creates a client automatically using the default kubeconfig, but this triggers a `FutureWarning`.\n\n## Environment Variables\n\n`get_client()` respects several environment variables automatically:\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `KUBECONFIG` | Path to the kubeconfig file | `~/.kube/config` |\n| `HTTPS_PROXY` | HTTPS proxy for cluster connections | None |\n| `HTTP_PROXY` | HTTP proxy (used if `HTTPS_PROXY` is not set) | None |\n\n> **Tip:** `HTTPS_PROXY` takes precedence over `HTTP_PROXY` when both are set.\n\nFor other environment variables that control logging, resource reuse, and teardown behavior, see [Environment Variables and Configuration](environment-variables.html).\n\n## Advanced Usage\n\n### Disabling TLS Verification\n\nFor development clusters with self-signed certificates:\n\n```python\nclient = get_client(\n host=\"https://api.dev-cluster.example.com:6443\",\n token=\"sha256~my-token\",\n verify_ssl=False,\n)\n```\n\n> **Warning:** Never disable TLS verification in production environments.\n\n### In-Cluster Configuration\n\nWhen your code runs inside a pod on the cluster (for example, in a CI/CD pipeline or operator), `get_client()` automatically falls back to in-cluster configuration if the kubeconfig-based connection fails:\n\n```python\n# Inside a pod \u2014 no config_file or host needed\nclient = get_client()\n```\n\nThe library first attempts to connect using the kubeconfig. If that fails with a connection error, it loads credentials from the pod's service account token mounted at `/var/run/secrets/kubernetes.io/serviceaccount/`.\n\n### Generating a Kubeconfig File from a Token Connection\n\nWhen you connect with `host` and `token`, you might need a kubeconfig file on disk (for example, to pass to external tools). Use `generate_kubeconfig=True`:\n\n```python\nclient = get_client(\n host=\"https://api.my-cluster.example.com:6443\",\n token=\"sha256~my-token\",\n generate_kubeconfig=True,\n)\n\n# Access the generated kubeconfig path\nprint(client.kubeconfig)\n```\n\nThe generated kubeconfig file:\n- Is written to a temporary file with `0o600` permissions\n- Is automatically cleaned up when the process exits\n\nIf you already passed a `config_file`, the `generate_kubeconfig` flag reuses that file path instead of creating a new one.\n\n### Passing a Custom Client Configuration\n\nFor fine-grained control over TLS settings, timeouts, or proxy configuration, pass a `kubernetes.client.Configuration` object:\n\n```python\nimport kubernetes\nfrom ocp_resources.resource import get_client\n\nconfig = kubernetes.client.Configuration()\nconfig.verify_ssl = False\nconfig.proxy = \"http://my-proxy:8080\"\n\nclient = get_client(\n host=\"https://api.my-cluster.example.com:6443\",\n token=\"sha256~my-token\",\n client_configuration=config,\n)\n```\n\n### Connecting to Multiple Clusters\n\nCreate separate clients for each cluster and pass them to different resources:\n\n```python\nfrom ocp_resources.resource import get_client\nfrom ocp_resources.namespace import Namespace\n\nprod_client = get_client(config_file=\"/path/to/prod-kubeconfig\")\nstaging_client = get_client(config_file=\"/path/to/staging-kubeconfig\")\n\n# Query namespaces on both clusters\nprod_namespaces = list(Namespace.get(client=prod_client))\nstaging_namespaces = list(Namespace.get(client=staging_client))\n```\n\n### Using the Fake Client for Testing\n\nFor unit tests that don't need a real cluster, pass `fake=True`:\n\n```python\nclient = get_client(fake=True)\n```\n\nThis returns a fake client that simulates Kubernetes API operations in memory. See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for full details.\n\n## `get_client()` API Reference\n\n```python\nfrom ocp_resources.resource import get_client\n\nget_client(\n config_file: str | None = None,\n config_dict: dict | None = None,\n context: str | None = None,\n client_configuration: kubernetes.client.Configuration | None = None,\n persist_config: bool = True,\n temp_file_path: str | None = None,\n try_refresh_token: bool = True,\n username: str | None = None,\n password: str | None = None,\n host: str | None = None,\n verify_ssl: bool | None = None,\n token: str | None = None,\n fake: bool = False,\n generate_kubeconfig: bool = False,\n) -> DynamicClient\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `config_file` | `str \\| None` | Path to a kubeconfig file |\n| `config_dict` | `dict \\| None` | Kubeconfig as a Python dictionary |\n| `context` | `str \\| None` | Name of the kubeconfig context to use |\n| `client_configuration` | `Configuration \\| None` | Custom `kubernetes.client.Configuration` object |\n| `persist_config` | `bool` | Whether to persist config changes back to the kubeconfig file |\n| `temp_file_path` | `str \\| None` | Path for temporary kubeconfig file (used with `config_dict`) |\n| `try_refresh_token` | `bool` | Attempt to refresh the authentication token (for in-cluster config) |\n| `username` | `str \\| None` | Username for basic authentication |\n| `password` | `str \\| None` | Password for basic authentication |\n| `host` | `str \\| None` | Cluster API server URL (e.g., `https://api.example.com:6443`) |\n| `verify_ssl` | `bool \\| None` | Set to `False` to skip TLS certificate verification |\n| `token` | `str \\| None` | Bearer token for authentication |\n| `fake` | `bool` | Return a fake in-memory client for testing |\n| `generate_kubeconfig` | `bool` | Save connection info to a temporary kubeconfig file accessible via `client.kubeconfig` |\n\n**Returns:** `DynamicClient` \u2014 a Kubernetes dynamic client ready for resource operations.\n\n**Raises:**\n- `kubernetes.config.ConfigException` \u2014 if no valid kubeconfig is found and in-cluster config is unavailable\n- `urllib3.exceptions.MaxRetryError` \u2014 if the cluster is unreachable (before falling back to in-cluster config)\n- `ClientWithBasicAuthError` \u2014 if basic auth (username/password) fails\n\n## Authentication Method Priority\n\nWhen multiple authentication parameters are provided, `get_client()` uses this priority order:\n\n1. **Basic auth** \u2014 if `username`, `password`, and `host` are all provided\n2. **Token auth** \u2014 if `host` and `token` are provided\n3. **Config dict** \u2014 if `config_dict` is provided\n4. **Kubeconfig file** \u2014 uses `config_file`, `KUBECONFIG` env var, or `~/.kube/config`\n5. **In-cluster config** \u2014 automatic fallback if the kubeconfig connection fails\n\n## Troubleshooting\n\n**\"FutureWarning: 'client' arg will be mandatory\"**\nYou are creating a resource without passing a `client`. Always call `get_client()` first and pass the result:\n\n```python\nclient = get_client()\nns = Namespace(client=client, name=\"my-ns\") # Pass client explicitly\n```\n\n**Connection refused or timeout errors**\nVerify your cluster is reachable and the kubeconfig or token is correct. Check that `HTTPS_PROXY` / `HTTP_PROXY` environment variables are set correctly if you are behind a proxy.\n\n**\"KUBECONFIG environment variable set in code is not detected\"**\nThe Kubernetes Python client reads `KUBECONFIG` at module import time. If you set it dynamically in your code, pass the path directly instead:\n\n```python\nimport os\n\n# Don't rely on: os.environ[\"KUBECONFIG\"] = \"/path/to/config\"\n# Instead, pass it explicitly:\nclient = get_client(config_file=os.environ.get(\"KUBECONFIG\", \"/path/to/config\"))\n```\n\n**Basic auth fails with `ClientWithBasicAuthError`**\nBasic auth uses the OpenShift OAuth flow. Ensure your cluster has a well-known OAuth endpoint at `/.well-known/oauth-authorization-server`. This method does not work with vanilla Kubernetes clusters.\n\n## Related Pages\n\n- [Installing and Creating Your First Resource](quickstart.html)\n- [Environment Variables and Configuration](environment-variables.html)\n- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Error Handling and Exception Reference](error-handling.html)\n"}, {"slug": "creating-and-managing-resources", "title": "Creating and Managing Resources", "content": "# Creating and Managing Resources\n\nDeploy Kubernetes and OpenShift resources to your cluster, update them in place, and clean them up reliably using the openshift-python-wrapper's resource lifecycle methods.\n\n## Prerequisites\n\n- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html))\n- A connected client \u2014 either a real cluster connection or a fake client for testing (see [Connecting to Clusters](connecting-to-clusters.html))\n\n## Quick Example\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\n# Create, use, and automatically delete a Namespace\nwith Namespace(client=client, name=\"my-app\") as ns:\n print(f\"{ns.name} is ready\")\n# Namespace is deleted when the block exits\n```\n\n## Creating Resources\n\nEvery resource provides three ways to create it: calling `deploy()` directly, using a context manager, or loading from a YAML file.\n\n### Using `deploy()` and `clean_up()`\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"my-namespace\")\nns.deploy()\n\n# ... do work ...\n\nns.clean_up() # Deletes the resource and waits for deletion\n```\n\n`deploy()` returns the resource instance, so you can chain:\n\n```python\nns = Namespace(client=client, name=\"my-namespace\").deploy()\n```\n\nPass `wait=True` to `deploy()` to block until the resource reaches its ready state:\n\n```python\nns = Namespace(client=client, name=\"my-namespace\")\nns.deploy(wait=True)\n```\n\n### Using a Context Manager\n\nThe context manager calls `deploy()` on entry and `clean_up()` on exit, guaranteeing cleanup even if an exception occurs:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\n\nwith ConfigMap(\n client=client,\n name=\"app-config\",\n namespace=\"my-namespace\",\n data={\"database_url\": \"postgres://db:5432/myapp\"},\n) as cm:\n print(cm.instance.data)\n# ConfigMap is automatically deleted here\n```\n\n> **Note:** If cleanup fails during context manager exit, a `ResourceTeardownError` is raised so failures are never silently ignored.\n\n### Namespaced vs. Cluster-Scoped Resources\n\nResources that live within a namespace inherit from `NamespacedResource` and require both `name` and `namespace`:\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n client=client,\n name=\"nginx\",\n namespace=\"my-namespace\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n)\npod.deploy()\n```\n\nCluster-scoped resources (like `Namespace`) inherit from `Resource` and need only `name`:\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"my-namespace\")\nns.deploy()\n```\n\n### Creating Resources from a YAML File\n\nPass `yaml_file` to create any resource directly from a YAML manifest. The resource name and namespace are read from the file:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\n\ncm = ConfigMap(client=client, yaml_file=\"manifests/configmap.yaml\")\ncm.deploy()\n```\n\n`yaml_file` also accepts a `StringIO` object for in-memory YAML:\n\n```python\nfrom io import StringIO\nfrom ocp_resources.config_map import ConfigMap\n\nyaml_content = StringIO(\"\"\"\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: dynamic-config\n namespace: my-namespace\ndata:\n setting: \"enabled\"\n\"\"\")\n\ncm = ConfigMap(client=client, yaml_file=yaml_content)\ncm.deploy()\n```\n\n### Creating Resources from a Dictionary\n\nUse `kind_dict` to pass a pre-built dictionary. When `kind_dict` is provided, the resource class's `to_dict()` logic is bypassed entirely:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\n\nresource_dict = {\n \"apiVersion\": \"v1\",\n \"kind\": \"ConfigMap\",\n \"metadata\": {\n \"name\": \"from-dict\",\n \"namespace\": \"my-namespace\",\n },\n \"data\": {\"key\": \"value\"},\n}\n\ncm = ConfigMap(client=client, kind_dict=resource_dict)\ncm.deploy()\n```\n\n> **Warning:** `yaml_file` and `kind_dict` are mutually exclusive. Passing both raises a `ValueError`.\n\n### Adding Labels and Annotations\n\nPass `label` and `annotations` at construction time:\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(\n client=client,\n name=\"labeled-ns\",\n label={\"team\": \"platform\", \"env\": \"staging\"},\n annotations={\"description\": \"Staging environment namespace\"},\n)\nns.deploy()\n```\n\n## Updating Resources\n\n### Patching with `update()`\n\n`update()` sends a merge patch \u2014 it adds or modifies fields without removing existing ones:\n\n```python\ncm = ConfigMap(\n client=client,\n name=\"app-config\",\n namespace=\"my-namespace\",\n ensure_exists=True,\n)\n\ncm.update(\n resource_dict={\n \"data\": {\"new_key\": \"new_value\"},\n }\n)\n```\n\n### Replacing with `update_replace()`\n\n`update_replace()` performs a full replacement of the resource. Use this when you need to **remove** fields that `update()` would leave untouched:\n\n```python\n# Get the current state\ncurrent = cm.instance.to_dict()\n\n# Remove a key\ncurrent[\"data\"].pop(\"old_key\", None)\n\n# Replace the entire resource\ncm.update_replace(resource_dict=current)\n```\n\n| Method | HTTP Verb | Behavior | Use When |\n|--------|-----------|----------|----------|\n| `update()` | PATCH | Merges fields into existing resource | Adding or changing fields |\n| `update_replace()` | PUT | Replaces entire resource | Removing fields or doing full replacements |\n\n## Deleting Resources\n\n### Using `clean_up()`\n\n```python\nns.clean_up() # Deletes and waits for removal (default: wait=True)\n```\n\nControl the deletion behavior:\n\n```python\n# Delete without waiting\nns.clean_up(wait=False)\n\n# Delete with a custom timeout (in seconds)\nns.clean_up(timeout=120)\n```\n\n`clean_up()` returns `True` if the resource was successfully deleted, `False` otherwise.\n\n### Using `delete()`\n\n`delete()` is the lower-level method called by `clean_up()`:\n\n```python\nns.delete(wait=True, timeout=240)\n```\n\nYou can also pass a custom body for delete options:\n\n```python\nns.delete(body={\"propagationPolicy\": \"Background\"})\n```\n\n> **Tip:** If you delete a resource that doesn't exist, `delete()` logs a warning and returns `True` instead of raising an error.\n\n### Controlling Teardown\n\nSet `teardown=False` at construction to prevent automatic deletion in context managers:\n\n```python\nwith Namespace(client=client, name=\"persistent-ns\", teardown=False) as ns:\n print(\"This namespace will NOT be deleted on exit\")\n```\n\n## Checking Resource State\n\n### Check If a Resource Exists\n\n```python\nns = Namespace(client=client, name=\"my-namespace\")\nif ns.exists:\n print(\"Namespace exists\")\n```\n\n### Ensure a Resource Already Exists\n\nUse `ensure_exists=True` to raise `ResourceNotFoundError` if the resource is not present on the cluster:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\n\n# Raises ResourceNotFoundError if the ConfigMap doesn't exist\ncm = ConfigMap(\n client=client,\n name=\"must-exist\",\n namespace=\"default\",\n ensure_exists=True,\n)\n```\n\n### Get the Live Instance\n\nThe `instance` property fetches the current state from the API server:\n\n```python\nns = Namespace(client=client, name=\"my-namespace\", ensure_exists=True)\nprint(ns.instance.metadata.labels)\nprint(ns.status)\n```\n\n### Export as YAML\n\n```python\nns = Namespace(client=client, name=\"my-namespace\")\nprint(ns.to_yaml())\n```\n\nFor more on querying, watching, and status checks, see [Querying and Watching Resources](querying-resources.html).\n\n## Dry Run Mode\n\nValidate a resource against the API server without actually creating it:\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"dry-run-ns\", dry_run=True)\nns.deploy() # Sends the request with ?dryRun=All \u2014 no resource is created\n```\n\n## Advanced Usage\n\n### Constructor Parameters Reference\n\nAll resource classes accept these common parameters:\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `client` | `DynamicClient` | `None` | Cluster client connection (will be required in next major release) |\n| `name` | `str` | `None` | Resource name |\n| `namespace` | `str` | `None` | Namespace (namespaced resources only) |\n| `teardown` | `bool` | `True` | Whether `clean_up()` deletes the resource |\n| `yaml_file` | `str` | `None` | Path to a YAML manifest file |\n| `kind_dict` | `dict` | `None` | Dictionary representation of the resource |\n| `delete_timeout` | `int` | `240` | Timeout in seconds for delete operations |\n| `dry_run` | `bool` | `False` | Server-side dry run without persisting |\n| `label` | `dict` | `None` | Labels to set on the resource |\n| `annotations` | `dict` | `None` | Annotations to set on the resource |\n| `ensure_exists` | `bool` | `False` | Raise if resource doesn't already exist |\n| `wait_for_resource` | `bool` | `False` | Wait for resource creation in context manager |\n| `schema_validation_enabled` | `bool` | `False` | Validate against OpenAPI schema on create/replace |\n| `hash_log_data` | `bool` | `True` | Mask sensitive data in logs |\n\nFor the full API reference, see [Resource and NamespacedResource API](resource-api.html).\n\n### Schema Validation on Create and Replace\n\nEnable automatic schema validation so `create()` and `update_replace()` check your resource against its OpenAPI schema before sending it to the API server:\n\n```python\nfrom ocp_resources.config_map import ConfigMap\n\ncm = ConfigMap(\n client=client,\n name=\"validated-cm\",\n namespace=\"default\",\n data={\"key\": \"value\"},\n schema_validation_enabled=True, # Validates before create()\n)\ncm.deploy()\n```\n\nYou can also validate on demand or validate a dictionary directly:\n\n```python\n# Validate an existing resource instance\ncm.validate()\n\n# Validate a dictionary without creating a resource\nConfigMap.validate_dict({\n \"apiVersion\": \"v1\",\n \"kind\": \"ConfigMap\",\n \"metadata\": {\"name\": \"test\"},\n \"data\": {\"key\": \"value\"},\n})\n```\n\nBoth methods raise `ValidationError` if validation fails. For more details, see [Validating Resources Against OpenAPI Schemas](validating-resources.html).\n\n### Temporary Edits with ResourceEditor\n\n`ResourceEditor` applies patches to existing resources and automatically restores original values when used as a context manager:\n\n```python\nfrom ocp_resources.resource import ResourceEditor\n\nns = Namespace(client=client, name=\"my-namespace\", ensure_exists=True)\n\nwith ResourceEditor(patches={ns: {\"metadata\": {\"labels\": {\"temporary-label\": \"true\"}}}}):\n # Label is applied\n assert ns.labels[\"temporary-label\"] == \"true\"\n# Label is automatically removed\n```\n\n`ResourceEditor` supports both `update` (merge patch) and `replace` actions:\n\n```python\nwith ResourceEditor(\n patches={ns: {\"metadata\": {\"labels\": {\"keep-only-this\": \"true\"}}}},\n action=\"replace\",\n):\n pass # Full replacement applied, then restored on exit\n```\n\nFor complete coverage, see [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html).\n\n### Managing Multiple Resources\n\n#### ResourceList \u2014 Create N Copies\n\nCreate multiple instances of the same resource type with indexed names:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import ResourceList\n\nwith ResourceList(\n client=client,\n resource_class=Namespace,\n name=\"test-ns\",\n num_resources=3,\n) as namespaces:\n # Creates test-ns-1, test-ns-2, test-ns-3\n for ns in namespaces:\n print(ns.name)\n# All three namespaces are deleted on exit\n```\n\n#### NamespacedResourceList \u2014 One Per Namespace\n\nCreate one resource in each namespace from a `ResourceList`:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import NamespacedResourceList, ResourceList\n\nnamespaces = ResourceList(\n client=client,\n resource_class=Namespace,\n name=\"env\",\n num_resources=3,\n)\n\nwith NamespacedResourceList(\n client=client,\n resource_class=Pod,\n namespaces=namespaces,\n name=\"worker\",\n containers=[{\"name\": \"app\", \"image\": \"myapp:latest\"}],\n) as pods:\n for pod in pods:\n print(f\"{pod.name} in {pod.namespace}\")\n```\n\nFor detailed patterns, see [Managing Bulk Resources with ResourceList](managing-resource-lists.html).\n\n### Debugging with Environment Variables\n\nThese environment variables control resource lifecycle behavior at runtime without code changes:\n\n| Variable | Effect |\n|----------|--------|\n| `REUSE_IF_RESOURCE_EXISTS` | Skip `deploy()` if the resource already exists |\n| `SKIP_RESOURCE_TEARDOWN` | Skip `clean_up()` to keep resources for debugging |\n\nBoth accept a YAML-formatted dictionary. Spaces are significant:\n\n```bash\n# Skip creation of all Pods\nexport REUSE_IF_RESOURCE_EXISTS=\"{Pod: {}}\"\n\n# Skip creation of a specific Pod in a specific namespace\nexport REUSE_IF_RESOURCE_EXISTS=\"{Pod: {my-pod: my-namespace}}\"\n\n# Skip teardown for a Namespace and a Pod\nexport SKIP_RESOURCE_TEARDOWN=\"{Namespace: {my-ns:}, Pod: {my-pod: my-namespace}}\"\n```\n\nFor the full list of environment variables, see [Environment Variables and Configuration](environment-variables.html).\n\n## Troubleshooting\n\n**`MissingRequiredArgumentError` on construction**\nYou must provide either `name`, `yaml_file`, or `kind_dict`. For namespaced resources, both `name` and `namespace` are required unless using `yaml_file` or `kind_dict`.\n\n**`ResourceTeardownError` in context manager**\nThe resource's `clean_up()` returned `False`, meaning deletion failed. Check cluster permissions and resource finalizers.\n\n**`ValueError: yaml_file and resource_dict are mutually exclusive`**\nYou passed both `yaml_file` and `kind_dict` to a constructor. Use only one.\n\n**`ResourceNotFoundError` with `ensure_exists=True`**\nThe resource doesn't exist on the cluster. Verify the name (and namespace for namespaced resources) are correct.\n\n**Resource not deleted after context manager exits**\nCheck if `teardown=False` was passed. Also verify the `SKIP_RESOURCE_TEARDOWN` environment variable is not set.\n\n## Related Pages\n\n- [Installing and Creating Your First Resource](quickstart.html)\n- [Querying and Watching Resources](querying-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html)\n- [Resource and NamespacedResource API](resource-api.html)\n"}, {"slug": "querying-resources", "title": "Querying and Watching Resources", "content": "# Querying and Watching Resources\n\nFind, filter, and monitor Kubernetes and OpenShift resources in your cluster \u2014 whether you need to list all pods by label, check if a resource exists, watch for real-time changes, or inspect status and conditions.\n\n## Prerequisites\n\n- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html))\n- A connected `DynamicClient` instance (see [Connecting to Clusters](connecting-to-clusters.html))\n\n## Quick Example\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\n# List all pods in a namespace\nfor pod in Pod.get(client=client, namespace=\"my-namespace\"):\n print(f\"{pod.name} \u2014 {pod.status}\")\n```\n\n## Listing Resources\n\nEvery resource class provides a `get()` class method that returns a generator of resource objects. The method accepts standard Kubernetes query parameters as keyword arguments.\n\n### List all resources of a type\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nfor ns in Namespace.get(client=client):\n print(ns.name)\n```\n\n### Filter by namespace\n\nNamespaced resources (like `Pod`, `Service`, `ConfigMap`) accept a `namespace` keyword:\n\n```python\nfrom ocp_resources.pod import Pod\n\nfor pod in Pod.get(client=client, namespace=\"default\"):\n print(pod.name)\n```\n\n### Filter by label selector\n\nUse `label_selector` with standard Kubernetes label selector syntax:\n\n```python\nfrom ocp_resources.pod import Pod\n\n# Single label\nfor pod in Pod.get(client=client, namespace=\"my-app\", label_selector=\"app=nginx\"):\n print(pod.name)\n\n# Multiple labels (comma-separated)\nfor pod in Pod.get(client=client, label_selector=\"app=nginx,tier=frontend\"):\n print(pod.name)\n```\n\n### Filter by field selector\n\nUse `field_selector` with standard Kubernetes field selector syntax:\n\n```python\nfrom ocp_resources.pod import Pod\n\n# Find pods on a specific node\nfor pod in Pod.get(client=client, field_selector=\"spec.nodeName=worker-1\"):\n print(pod.name)\n\n# Find pods by status phase\nfor pod in Pod.get(client=client, namespace=\"default\", field_selector=\"status.phase=Running\"):\n print(pod.name)\n```\n\n### Get raw resource objects\n\nBy default, `get()` returns wrapper objects. Pass `raw=True` to get the underlying `ResourceInstance` or `ResourceField` objects directly:\n\n```python\nfrom ocp_resources.pod import Pod\n\nfor pod in Pod.get(client=client, namespace=\"default\", raw=True):\n # Returns raw ResourceField objects instead of Pod instances\n print(pod.metadata.name, pod.status.phase)\n```\n\n### List all cluster resources\n\nTo iterate over every resource type in the cluster:\n\n```python\nfrom ocp_resources.resource import Resource\n\nfor resource in Resource.get_all_cluster_resources(client=client):\n print(f\"{resource.kind}: {resource.metadata.name}\")\n\n# Filter with label selector\nfor resource in Resource.get_all_cluster_resources(client=client, label_selector=\"my-label=value\"):\n print(f\"{resource.kind}: {resource.metadata.name}\")\n```\n\n## `get()` Method Reference\n\n```python\n@classmethod\ndef get(\n cls,\n client: DynamicClient | None = None,\n singular_name: str = \"\",\n exceptions_dict: dict[type[Exception], list[str]] = DEFAULT_CLUSTER_RETRY_EXCEPTIONS,\n raw: bool = False,\n *args,\n **kwargs,\n) -> Generator\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `client` | `DynamicClient` | Kubernetes dynamic client (required) |\n| `singular_name` | `str` | Resource kind in lowercase; used when multiple resources match the same kind |\n| `raw` | `bool` | If `True`, return raw `ResourceInstance`/`ResourceField` objects instead of wrapper instances |\n| `exceptions_dict` | `dict` | Exceptions to retry on during API calls |\n| `**kwargs` | | Passed through to the Kubernetes API: `namespace`, `label_selector`, `field_selector`, `limit`, `resource_version`, `timeout_seconds`, etc. |\n\n**Returns:** A generator yielding resource objects.\n\n## Checking Resource Existence\n\nThe `exists` property queries the API server and returns the resource instance if it exists, or `None` if it does not.\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"my-namespace\")\n\nif ns.exists:\n print(\"Namespace exists\")\nelse:\n print(\"Namespace not found\")\n```\n\n### Require existence at initialization\n\nUse `ensure_exists=True` to raise `ResourceNotFoundError` immediately if the resource is not found:\n\n```python\nfrom ocp_resources.pod import Pod\n\n# Raises ResourceNotFoundError if the pod doesn't exist\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\", ensure_exists=True)\n```\n\n> **Tip:** Use `ensure_exists=True` when you need a reference to a resource that must already be on the cluster, such as a pre-existing ConfigMap or Secret.\n\n## Getting the Resource Instance\n\nThe `instance` property fetches the full live resource from the API server:\n\n```python\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\n# Get the full resource instance\ninst = pod.instance\nprint(inst.metadata.name)\nprint(inst.metadata.uid)\nprint(inst.metadata.resourceVersion)\n\n# Convert to a plain dictionary\nresource_dict = pod.instance.to_dict()\n```\n\n> **Note:** Each access to `.instance` makes an API call. Cache the result in a local variable if you need multiple fields from the same snapshot.\n\n## Retrieving Labels\n\nThe `labels` property returns the resource's labels:\n\n```python\nns = Namespace(client=client, name=\"my-namespace\")\nprint(ns.labels) # {'kubernetes.io/metadata.name': 'my-namespace', ...}\n\n# Check a specific label\nif \"app\" in ns.labels:\n print(f\"App label: {ns.labels['app']}\")\n```\n\n## Retrieving Resource Status\n\n### Status phase\n\nThe `status` property returns the current phase (e.g., `Running`, `Pending`, `Active`):\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"my-namespace\")\nprint(ns.status) # \"Active\"\n```\n\nEach resource class defines status constants you can compare against:\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\nif pod.status == Pod.Status.RUNNING:\n print(\"Pod is running\")\n```\n\nCommon status constants available on all resources:\n\n| Constant | Value |\n|---|---|\n| `Status.RUNNING` | `\"Running\"` |\n| `Status.PENDING` | `\"Pending\"` |\n| `Status.SUCCEEDED` | `\"Succeeded\"` |\n| `Status.FAILED` | `\"Failed\"` |\n| `Status.ACTIVE` | `\"Active\"` |\n| `Status.TERMINATING` | `\"Terminating\"` |\n| `Status.COMPLETED` | `\"Completed\"` |\n| `Status.ERROR` | `\"Error\"` |\n\n### Condition messages\n\nUse `get_condition_message()` to retrieve the message for a specific condition type:\n\n```python\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\n# Get message for a condition type\nmsg = pod.get_condition_message(condition_type=\"Ready\")\nprint(msg)\n\n# Optionally filter by condition status\nmsg = pod.get_condition_message(\n condition_type=\"Ready\",\n condition_status=\"False\",\n)\nprint(msg) # Returns empty string if status doesn't match\n```\n\n**Signature:**\n```python\ndef get_condition_message(\n self,\n condition_type: str,\n condition_status: str = \"\",\n) -> str\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `condition_type` | `str` | The condition type to look up (e.g., `\"Ready\"`, `\"Available\"`) |\n| `condition_status` | `str` | If provided, only return the message when the condition has this status |\n\n**Returns:** The condition message string, or `\"\"` if not found or status doesn't match.\n\n### Condition constants\n\nResources provide built-in condition constants:\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\n# Use condition constants\nmsg = pod.get_condition_message(\n condition_type=Pod.Condition.READY,\n condition_status=Pod.Condition.Status.TRUE,\n)\n```\n\nCommon condition constants:\n\n| Constant | Value |\n|---|---|\n| `Condition.READY` | `\"Ready\"` |\n| `Condition.AVAILABLE` | `\"Available\"` |\n| `Condition.PROGRESSING` | `\"Progressing\"` |\n| `Condition.DEGRADED` | `\"Degraded\"` |\n| `Condition.UPGRADEABLE` | `\"Upgradeable\"` |\n| `Condition.Status.TRUE` | `\"True\"` |\n| `Condition.Status.FALSE` | `\"False\"` |\n| `Condition.Status.UNKNOWN` | `\"Unknown\"` |\n\n## Watching for Changes\n\n### Watch a specific resource\n\nThe `watcher()` method streams events for a specific resource instance:\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\nfor event in pod.watcher(timeout=60):\n print(f\"Event type: {event['type']}\") # ADDED, MODIFIED, DELETED\n print(f\"Object: {event['object'].metadata.name}\")\n print(f\"Raw: {event['raw_object']}\")\n```\n\n**Signature:**\n```python\ndef watcher(\n self,\n timeout: int,\n resource_version: str = \"\",\n) -> Generator[dict[str, Any], None, None]\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `timeout` | `int` | How long to watch (in seconds) |\n| `resource_version` | `str` | Only receive events newer than this version. Defaults to the resource version at creation time. |\n\nEach yielded event is a dict with these keys:\n\n| Key | Description |\n|---|---|\n| `type` | Event type: `\"ADDED\"`, `\"MODIFIED\"`, or `\"DELETED\"` |\n| `object` | A `ResourceInstance` wrapping the resource |\n| `raw_object` | A plain dict representing the resource |\n\n## Querying Events\n\n### Stream events for a resource\n\nThe `events()` method watches Kubernetes events associated with a resource:\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\nfor event in pod.events(timeout=30):\n print(f\"Reason: {event.object.reason}\")\n print(f\"Message: {event.object.message}\")\n```\n\n**Signature:**\n```python\ndef events(\n self,\n name: str = \"\",\n label_selector: str = \"\",\n field_selector: str = \"\",\n resource_version: str = \"\",\n timeout: int = 240,\n) -> Generator\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `name` | `str` | Filter by event name |\n| `label_selector` | `str` | Filter by labels (comma-separated `key=value`) |\n| `field_selector` | `str` | Additional field filters (merged with auto-generated `involvedObject.name` filter) |\n| `resource_version` | `str` | Only return events newer than this version |\n| `timeout` | `int` | Watch timeout in seconds (default: 240) |\n\n```python\n# Example: filter for Warning events with a specific reason\nfor event in pod.events(\n field_selector=\"type==Warning,reason==BackOff\",\n timeout=10,\n):\n print(event.object.message)\n```\n\n### List existing events (non-streaming)\n\nUse `Event.list()` to get a snapshot of existing events without streaming:\n\n```python\nfrom ocp_resources.event import Event\n\n# List Warning events from the last 5 minutes\nevents = Event.list(\n client=client,\n namespace=\"my-namespace\",\n field_selector=\"type==Warning\",\n)\nfor event in events:\n print(f\"{event.reason}: {event.message}\")\n\n# List events from the last 10 minutes\nevents = Event.list(\n client=client,\n namespace=\"my-namespace\",\n since_seconds=600,\n)\n```\n\n**Signature:**\n```python\n@classmethod\ndef Event.list(\n cls,\n client: DynamicClient,\n namespace: str | None = None,\n field_selector: str | None = None,\n label_selector: str | None = None,\n since_seconds: int = 300,\n) -> list\n```\n\nEvents are returned sorted by timestamp (most recent first).\n\n### Delete events\n\nClean up events before tests to avoid false positives:\n\n```python\nfrom ocp_resources.event import Event\n\nEvent.delete_events(\n client=client,\n namespace=\"my-namespace\",\n field_selector=\"reason=AnEventReason\",\n)\n```\n\n## Advanced Usage\n\n### Handling resources with duplicate API groups\n\nSome resource kinds exist in multiple API groups. Use `singular_name` to disambiguate:\n\nThe `DNS` kind exists in two API groups, so class-generator writes one module per group:\n\n- `ocp_resources.dns_config_openshift_io` \u2014 `config.openshift.io/v1`\n- `ocp_resources.dns_operator_openshift_io` \u2014 `operator.openshift.io/v1`\n\n```python\nfrom ocp_resources.dns_config_openshift_io import DNS\n\n# When a kind exists in multiple API groups, pass singular_name\nfor dns in DNS.get(client=client, singular_name=\"dns\"):\n print(dns.name)\n```\n\n### Using `full_api()` for low-level access\n\nThe `full_api()` method returns the raw Kubernetes API resource object, giving you direct access to the API:\n\n```python\nns = Namespace(client=client, name=\"my-namespace\")\napi = ns.full_api()\n\n# Use the API directly for custom queries\nresult = api.get(\n label_selector=\"app=nginx\",\n field_selector=\"status.phase=Active\",\n limit=10,\n)\n```\n\n`full_api()` accepts these keyword arguments:\n\n| Parameter | Description |\n|---|---|\n| `pretty` | Pretty-print output |\n| `_continue` | Continuation token for paginated results |\n| `field_selector` | Filter by fields |\n| `label_selector` | Filter by labels |\n| `limit` | Maximum number of results |\n| `resource_version` | Filter by resource version |\n| `timeout_seconds` | Server-side timeout |\n| `watch` | Enable watch mode |\n\n### Converting resources to YAML\n\n```python\nns = Namespace(client=client, name=\"my-namespace\")\nprint(ns.to_yaml())\n```\n\n### Wait for conditions and status\n\nFor polling-based waiting (e.g., wait until a pod is `Running` or a condition is `True`), see [Waiting for Resource Conditions and Status](waiting-for-conditions.html).\n\n### Querying during tests with the fake client\n\nYou can use all querying methods with the fake client for unit testing:\n\n```python\nfrom ocp_resources.resource import get_client\nfrom ocp_resources.namespace import Namespace\n\nclient = get_client(fake=True)\nns = Namespace(client=client, name=\"test-ns\")\nns.deploy()\n\n# All query methods work the same way\nassert ns.exists\nassert ns.instance\nassert ns.status == Namespace.Status.ACTIVE\n\n# Listing works too\nfor ns in Namespace.get(client=client):\n print(ns.name)\n```\n\nSee [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for details.\n\n## Troubleshooting\n\n**`ResourceNotFoundError` when using `ensure_exists=True`**\nThe resource does not exist on the cluster. Verify the name, namespace, and that the resource has been created before initializing the wrapper.\n\n**`NotImplementedError: Couldn't find in `**\nThe resource kind is not available on your cluster. This typically means the CRD has not been installed or the API group is incorrect.\n\n**`get()` returns no results**\n- Verify the `namespace` is correct (omit it for cluster-scoped resources).\n- Check your `label_selector` and `field_selector` syntax \u2014 use `==` for equality (e.g., `field_selector=\"status.phase==Running\"`).\n- Ensure the client has permissions to list the resource type.\n\n**`status` property raises `AttributeError`**\nNot all resources have a `status.phase` field. Access `instance.status` directly to inspect what status fields are available for your resource type.\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Working with Kubernetes Events](working-with-events.html)\n- [Common Resource Patterns](common-patterns.html)\n- [Resource and NamespacedResource API](resource-api.html)\n"}, {"slug": "waiting-for-conditions", "title": "Waiting for Resource Conditions and Status", "content": "# Waiting for Resource Conditions and Status\n\nWait for Kubernetes and OpenShift resources to reach a desired status, condition, or lifecycle state using built-in polling methods with configurable timeouts and early exit on failure.\n\n## Prerequisites\n\n- `openshift-python-wrapper` installed in your project\n- A connected client (see [Connecting to Clusters](connecting-to-clusters.html))\n- A resource instance to wait on (see [Creating and Managing Resources](creating-and-managing-resources.html))\n\n## Quick Example\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\npod.create(wait=True) # Creates the pod and waits for it to exist\n\n# Wait for the pod to reach \"Running\" status\npod.wait_for_status(status=Pod.Status.RUNNING)\n```\n\n## Waiting for a Resource to Exist\n\nThe `wait()` method polls until a resource exists on the cluster. This is useful after creating a resource or when waiting for an external controller to produce one.\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"my-namespace\")\nns.create()\nns.wait(timeout=120, sleep=2)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `timeout` | `int` | `240` (4 minutes) | Maximum seconds to wait |\n| `sleep` | `int` | `1` | Seconds between polls |\n\n**Raises:** `TimeoutExpiredError` if the resource does not appear before the timeout.\n\n> **Tip:** Pass `wait=True` to `create()` or `deploy()` to combine creation and existence waiting in one call:\n> ```python\n> pod.create(wait=True)\n> # or\n> pod.deploy(wait=True)\n> ```\n\n## Waiting for Deletion\n\nThe `wait_deleted()` method polls until a resource no longer exists:\n\n```python\npod.delete(wait=True, timeout=120)\n\n# Or call wait_deleted() separately:\npod.delete()\nsuccess = pod.wait_deleted(timeout=120)\nif not success:\n print(\"Resource still exists after timeout\")\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `timeout` | `int` | `240` (4 minutes) | Maximum seconds to wait |\n\n**Returns:** `True` if the resource was deleted, `False` if the timeout was reached.\n\n> **Note:** Unlike most wait methods, `wait_deleted()` returns `False` on timeout rather than raising an exception.\n\n## Waiting for Status (Phase)\n\nThe `wait_for_status()` method polls `status.phase` until it matches the expected value:\n\n```python\nfrom ocp_resources.datavolume import DataVolume\n\ndv = DataVolume(client=client, name=\"my-dv\", namespace=\"default\")\ndv.wait_for_status(status=DataVolume.Status.SUCCEEDED, timeout=600)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `status` | `str` | *(required)* | Expected `status.phase` value |\n| `timeout` | `int` | `240` (4 minutes) | Maximum seconds to wait |\n| `stop_status` | `str \\| None` | `Status.FAILED` | Status that aborts the wait immediately |\n| `sleep` | `int` | `1` | Seconds between polls |\n| `exceptions_dict` | `dict` | Protocol + cluster retry errors | Exceptions to catch and retry |\n\n**Raises:** `TimeoutExpiredError` if the resource does not reach the desired status, or if `stop_status` is encountered.\n\n### Using `stop_status` for early failure\n\nBy default, `wait_for_status()` aborts immediately if the resource enters a `\"Failed\"` phase. Override this to detect a different failure phase:\n\n```python\nfrom ocp_resources.virtual_machine_instance import VirtualMachineInstance\n\nvmi = VirtualMachineInstance(client=client, name=\"my-vmi\", namespace=\"default\")\nvmi.wait_for_status(\n status=VirtualMachineInstance.Status.RUNNING,\n stop_status=\"ErrorUnschedulable\",\n timeout=300,\n)\n```\n\n### Built-in status constants\n\nEvery resource inherits status constants from its base class. Resource-specific subclasses extend these with additional values:\n\n```python\n# Base status constants (available on all resources)\nfrom ocp_resources.resource import Resource\n\nResource.Status.SUCCEEDED # \"Succeeded\"\nResource.Status.FAILED # \"Failed\"\nResource.Status.RUNNING # \"Running\"\nResource.Status.PENDING # \"Pending\"\n\n# Resource-specific statuses\nfrom ocp_resources.virtual_machine import VirtualMachine\n\nVirtualMachine.Status.MIGRATING # \"Migrating\"\nVirtualMachine.Status.STOPPED # \"Stopped\"\nVirtualMachine.Status.STARTING # \"Starting\"\nVirtualMachine.Status.PAUSED # \"Paused\"\n\nfrom ocp_resources.persistent_volume_claim import PersistentVolumeClaim\n\nPersistentVolumeClaim.Status.BOUND # \"Bound\"\n```\n\n## Waiting for Conditions\n\nThe `wait_for_condition()` method waits for a specific entry in `status.conditions[]` to match a desired type, status, and optionally a reason or message:\n\n```python\nfrom ocp_resources.user_defined_network import UserDefinedNetwork\n\nudn = UserDefinedNetwork(client=client, name=\"my-net\", namespace=\"default\")\nudn.wait_for_condition(\n condition=UserDefinedNetwork.Condition.NETWORK_READY,\n status=UserDefinedNetwork.Condition.Status.TRUE,\n timeout=30,\n)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `condition` | `str` | *(required)* | Condition type to match (e.g. `\"Ready\"`, `\"Available\"`) |\n| `status` | `str` | *(required)* | Expected condition status (`\"True\"`, `\"False\"`, `\"Unknown\"`) |\n| `timeout` | `int` | `300` (5 minutes) | Maximum seconds to wait |\n| `sleep_time` | `int` | `1` | Seconds between polls |\n| `reason` | `str \\| None` | `None` | If set, the condition's `reason` field must also match |\n| `message` | `str` | `\"\"` | Text that must appear in the condition's `message` field |\n| `stop_condition` | `str \\| None` | `None` | A condition type that aborts the wait immediately |\n| `stop_status` | `str` | `\"True\"` | The status of the stop condition that triggers early abort |\n\n**Raises:**\n- `TimeoutExpiredError` \u2014 if the condition is not met before the timeout\n- `ConditionError` \u2014 if the `stop_condition` is detected before the desired condition\n\n### Matching by reason and message\n\n```python\npod.wait_for_condition(\n condition=\"Ready\",\n status=\"True\",\n reason=\"PodCompleted\",\n message=\"containers with\",\n timeout=120,\n)\n```\n\nThe `reason` must be an exact match. The `message` is checked using substring inclusion \u2014 the condition's message field must *contain* the provided string.\n\n### Using `stop_condition` for early abort\n\nStop conditions let you fail fast when an unrecoverable condition appears:\n\n```python\nfrom ocp_resources.exceptions import ConditionError\n\ntry:\n resource.wait_for_condition(\n condition=\"Ready\",\n status=\"True\",\n stop_condition=\"Failed\",\n stop_status=\"True\",\n timeout=300,\n )\nexcept ConditionError as e:\n print(f\"Resource hit a failure condition: {e}\")\n```\n\n> **Note:** The `stop_condition` matching only checks the condition `type` and `status`. The `reason` and `message` parameters are ignored when evaluating stop conditions.\n\n### Built-in condition constants\n\n```python\nfrom ocp_resources.resource import Resource\n\n# Condition types\nResource.Condition.READY # \"Ready\"\nResource.Condition.AVAILABLE # \"Available\"\nResource.Condition.DEGRADED # \"Degraded\"\nResource.Condition.PROGRESSING # \"Progressing\"\nResource.Condition.UPGRADEABLE # \"Upgradeable\"\nResource.Condition.NETWORK_READY # \"NetworkReady\"\n\n# Condition statuses\nResource.Condition.Status.TRUE # \"True\"\nResource.Condition.Status.FALSE # \"False\"\nResource.Condition.Status.UNKNOWN # \"Unknown\"\n\n# Condition reasons\nResource.Condition.Reason.ALL_REQUIREMENTS_MET # \"AllRequirementsMet\"\nResource.Condition.Reason.INSTALL_SUCCEEDED # \"InstallSucceeded\"\n```\n\n## Waiting with Context Managers\n\nWhen using a resource as a context manager, set `wait_for_resource=True` to automatically wait for the resource to exist after creation:\n\n```python\nfrom ocp_resources.pod import Pod\n\nwith Pod(\n client=client,\n name=\"my-pod\",\n namespace=\"default\",\n wait_for_resource=True,\n) as pod:\n # Pod exists when you reach this line\n pod.wait_for_status(status=Pod.Status.RUNNING)\n```\n\nSee [Creating and Managing Resources](creating-and-managing-resources.html) for more on context manager usage.\n\n## Advanced Usage\n\n### Waiting for any conditions to appear\n\nThe `wait_for_conditions()` method waits up to 30 seconds for the resource's `status.conditions` list to be populated (non-empty). This is useful when you need to inspect conditions but aren't sure they've been set yet:\n\n```python\nresource.wait_for_conditions()\n# Now resource.instance.status.conditions is available\n```\n\n### Resource-specific wait methods\n\nMany resource types provide specialized wait methods beyond the base `wait_for_status()` and `wait_for_condition()`. These methods encapsulate domain-specific logic.\n\n#### Deployments\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndeploy = Deployment(client=client, name=\"my-app\", namespace=\"default\")\ndeploy.wait_for_replicas(deployed=True, timeout=240) # All replicas ready\ndeploy.wait_for_replicas(deployed=False, timeout=120) # Scale to zero complete\n```\n\n#### DaemonSets\n\n```python\nfrom ocp_resources.daemonset import DaemonSet\n\nds = DaemonSet(client=client, name=\"my-ds\", namespace=\"default\")\nds.wait_until_deployed(timeout=240) # All desired pods are ready\n```\n\n#### VirtualMachines (KubeVirt)\n\n```python\nfrom ocp_resources.virtual_machine import VirtualMachine\nfrom ocp_resources.virtual_machine_instance import VirtualMachineInstance\n\nvm = VirtualMachine(client=client, name=\"my-vm\", namespace=\"default\")\n\n# Wait for VM ready status (True = running, None = stopped)\nvm.wait_for_ready_status(status=True, timeout=240)\n\n# Wait for a specific status field to become None\nvm.wait_for_status_none(status=\"snapshotInProgress\", timeout=240)\n\n# Wait for VMI to be running\nvmi = VirtualMachineInstance(client=client, name=\"my-vm\", namespace=\"default\")\nvmi.wait_until_running(timeout=240, logs=True)\n\n# Wait for VMI pause state\nvmi.wait_for_pause_status(pause=True, timeout=240)\n```\n\nSee [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) for the full VM lifecycle.\n\n#### DataVolumes\n\n```python\nfrom ocp_resources.datavolume import DataVolume\n\ndv = DataVolume(client=client, name=\"my-dv\", namespace=\"default\")\ndv.wait_for_dv_success(\n timeout=600,\n failure_timeout=120,\n pvc_wait_for_bound_timeout=60,\n)\n```\n\nThe `wait_for_dv_success()` method handles the full DataVolume lifecycle: it waits for the status to leave `Pending`, then waits for `Succeeded`, and finally confirms the PVC is `Bound`.\n\nYou can pass a `stop_status_func` callback to abort early on custom conditions:\n\n```python\ndef dv_is_stuck(dv):\n return dv.instance.status.conditions.restartCount > 3\n\n\ndv.wait_for_dv_success(\n stop_status_func=dv_is_stuck,\n dv=dv, # passed as keyword argument to the function\n)\n```\n\n#### VirtualMachineSnapshot / VirtualMachineRestore\n\n```python\nfrom ocp_resources.virtual_machine_snapshot import VirtualMachineSnapshot\nfrom ocp_resources.virtual_machine_restore import VirtualMachineRestore\n\nsnapshot = VirtualMachineSnapshot(client=client, name=\"snap-1\", namespace=\"default\")\nsnapshot.wait_snapshot_done(timeout=240) # readyToUse + VM snapshotInProgress is None\n\nrestore = VirtualMachineRestore(client=client, name=\"restore-1\", namespace=\"default\")\nrestore.wait_restore_done(timeout=240) # complete + VM restoreInProgress is None\n```\n\n#### MachineSet\n\n```python\nfrom ocp_resources.machine_set import MachineSet\n\nms = MachineSet(client=client, name=\"worker-set\", namespace=\"openshift-machine-api\")\nms.wait_for_replicas(timeout=300, sleep=1)\n```\n\nReturns `True` when ready replicas match desired replicas, `False` on timeout.\n\n#### NodeNetworkConfigurationPolicy\n\n```python\nfrom ocp_resources.node_network_configuration_policy import NodeNetworkConfigurationPolicy\n\nnncp = NodeNetworkConfigurationPolicy(client=client, name=\"br1-policy\")\nnncp.wait_for_status_success() # Uses the resource's own success_timeout\n```\n\nRaises `NNCPConfigurationFailed` if the policy fails or finds no matching nodes.\n\n### Timeout constants\n\nThe library provides predefined timeout constants you can use with any wait method:\n\n```python\nfrom ocp_resources.utils.constants import (\n TIMEOUT_1SEC, # 1\n TIMEOUT_5SEC, # 5\n TIMEOUT_10SEC, # 10\n TIMEOUT_30SEC, # 30\n TIMEOUT_1MINUTE, # 60\n TIMEOUT_2MINUTES, # 120\n TIMEOUT_4MINUTES, # 240\n TIMEOUT_10MINUTES, # 600\n)\n\npod.wait_for_status(status=Pod.Status.RUNNING, timeout=TIMEOUT_10MINUTES)\n```\n\n### Custom exception retries\n\nWait methods handle transient cluster errors automatically (connection resets, etcd leader changes, protocol errors). You can customize which exceptions are retried:\n\n```python\nfrom ocp_resources.utils.constants import (\n PROTOCOL_ERROR_EXCEPTION_DICT,\n DEFAULT_CLUSTER_RETRY_EXCEPTIONS,\n)\n\n# Combine default retry exceptions\npod.wait_for_status(\n status=Pod.Status.RUNNING,\n exceptions_dict={\n **PROTOCOL_ERROR_EXCEPTION_DICT,\n **DEFAULT_CLUSTER_RETRY_EXCEPTIONS,\n },\n)\n```\n\nThe default retry exceptions cover:\n- `ProtocolError` \u2014 network-level failures\n- `MaxRetryError`, `ConnectionAbortedError`, `ConnectionResetError` \u2014 connection failures\n- `InternalServerError` \u2014 etcd leader changes, webhook failures, RPC errors\n- `ServerTimeoutError` \u2014 API server timeouts\n- `ForbiddenError` \u2014 context deadline exceeded errors\n\n## Troubleshooting\n\n### `TimeoutExpiredError` is raised unexpectedly\n\n- **Increase the timeout.** The default is 240 seconds (4 minutes), which may not be enough for slow operations like DataVolume imports or VM migrations.\n- **Check `stop_status`.** `wait_for_status()` defaults `stop_status` to `Status.FAILED`. If your resource legitimately passes through a `\"Failed\"` phase, set `stop_status` to a value that won't match, or set it to `None`.\n- **Inspect the current status.** The error log includes the last observed status. Read it to understand where the resource got stuck.\n\n### `ConditionError` on `wait_for_condition()`\n\nThis means the `stop_condition` was matched before the desired condition. The error message includes the full condition details. Review your `stop_condition` and `stop_status` settings.\n\n### Resource exists but `wait_for_status()` times out\n\nThe `wait_for_status()` method reads from `status.phase`. Some resources use different status fields (e.g., `status.ready`, `status.readyToUse`, `status.syncStatus`). For those resources, use the resource-specific wait methods documented above, or use `wait_for_condition()` to match on `status.conditions[]` entries instead.\n\n## Related Pages\n\n- [Querying and Watching Resources](querying-resources.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html)\n- [Error Handling and Exception Reference](error-handling.html)\n- [Resource and NamespacedResource API](resource-api.html)\n"}, {"slug": "validating-resources", "title": "Validating Resources Against OpenAPI Schemas", "content": "# Validating Resources Against OpenAPI Schemas\n\nCatch configuration mistakes before they reach the Kubernetes API by validating resource definitions against OpenAPI schemas. This saves debugging time and prevents failed deployments caused by typos, missing fields, or incorrect types.\n\n## Prerequisites\n\n- openshift-python-wrapper installed (see [Installing and Creating Your First Resource](quickstart.html))\n- A connection to a cluster, or use the fake client for offline validation (see [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html))\n\n## Quick Example\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n name=\"nginx-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n)\n\n# Validate against the OpenAPI schema \u2014 raises ValidationError if invalid\npod.validate()\n```\n\nIf the resource is valid, `validate()` returns silently. If something is wrong, it raises a `ValidationError` with a clear message pointing to the problematic field.\n\n## Manual Validation\n\nCall `.validate()` on any resource instance to check it against the OpenAPI schema for its kind:\n\n```python\nfrom ocp_resources.exceptions import ValidationError\nfrom ocp_resources.service import Service\n\nservice = Service(\n name=\"nginx-service\",\n namespace=\"default\",\n selector={\"app\": \"nginx\"},\n ports=[{\"port\": 80, \"targetPort\": 80, \"protocol\": \"TCP\"}],\n)\n\ntry:\n service.validate()\n print(\"Service configuration is valid\")\nexcept ValidationError as e:\n print(f\"Validation failed: {e}\")\n```\n\n> **Tip:** Use manual validation in CI pipelines or pre-commit hooks to catch errors before applying resources to a cluster.\n\n## Validating Dictionaries Without Creating Objects\n\nUse the `validate_dict()` class method to validate a raw resource dictionary without instantiating a resource object:\n\n```python\nfrom ocp_resources.deployment import Deployment\nfrom ocp_resources.exceptions import ValidationError\n\ndeployment_dict = {\n \"apiVersion\": \"apps/v1\",\n \"kind\": \"Deployment\",\n \"metadata\": {\"name\": \"nginx-deployment\", \"namespace\": \"default\"},\n \"spec\": {\n \"replicas\": 3,\n \"selector\": {\"matchLabels\": {\"app\": \"nginx\"}},\n \"template\": {\n \"metadata\": {\"labels\": {\"app\": \"nginx\"}},\n \"spec\": {\n \"containers\": [{\"name\": \"nginx\", \"image\": \"nginx:1.21\", \"ports\": [{\"containerPort\": 80}]}],\n },\n },\n },\n}\n\ntry:\n Deployment.validate_dict(resource_dict=deployment_dict)\n print(\"Deployment configuration is valid\")\nexcept ValidationError as e:\n print(f\"Invalid: {e}\")\n```\n\nThis is useful when you have a dictionary from a YAML file or external source and want to validate it before creating the resource.\n\n## Enabling Auto-Validation on Create\n\nSet `schema_validation_enabled=True` when creating a resource instance to automatically validate before every `create()`, `deploy()`, or `update_replace()` call:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ValidationError\n\npod = Pod(\n client=client,\n name=\"auto-validated-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n schema_validation_enabled=True,\n)\n\n# Validation runs automatically before the resource is sent to the API\npod.deploy()\n```\n\nIf validation fails, the resource is **not** created and a `ValidationError` is raised:\n\n```python\ninvalid_pod = Pod(\n client=client,\n name=\"bad-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\"}], # Missing required 'image' field\n schema_validation_enabled=True,\n)\n\ntry:\n invalid_pod.deploy()\nexcept ValidationError as e:\n print(f\"Blocked: {e}\")\n```\n\n> **Note:** Auto-validation is **disabled by default** (`schema_validation_enabled=False`). You must opt in per instance.\n\n### When Auto-Validation Runs\n\n| Operation | Auto-validates? | Notes |\n|--------------------|-----------------|---------------------------------------------|\n| `create()` / `deploy()` | \u2705 Yes | Validates the full resource before sending |\n| `update_replace()` | \u2705 Yes | Validates the replacement dictionary |\n| `update()` | \u274c No | Sends a partial patch, not a full resource |\n\nAuto-validation also applies when using context managers, since they call `deploy()` internally. See [Creating and Managing Resources](creating-and-managing-resources.html) for details on resource lifecycle methods.\n\n### Toggling Validation at Runtime\n\nYou can enable or disable validation on an existing instance:\n\n```python\npod = Pod(\n client=client,\n name=\"my-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n)\n\n# Enable after creation\npod.schema_validation_enabled = True\npod.deploy() # Will validate first\n\n# Disable for a subsequent operation\npod.schema_validation_enabled = False\n```\n\n## Advanced Usage\n\n### Validating Resources with Duplicate API Groups\n\nSome resource kinds (like `DNS`) exist in multiple API groups. The validator uses the resource's `api_group` to select the correct schema automatically:\n\n```python\n# These two resources share the kind \"DNS\" but use different API groups.\n# Each is validated against its own schema.\ndns_config.validate() # Uses config.openshift.io schema\ndns_operator.validate() # Uses operator.openshift.io schema\n```\n\nSee [Common Resource Patterns](common-patterns.html) for more on working with resources that share a kind name.\n\n### Pre-Warming the Schema Cache\n\nThe first validation for a resource kind loads and resolves the schema (~25ms). Subsequent validations for the same kind use a cache and are much faster (~2ms). Pre-warm the cache at application startup for critical resource types:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.deployment import Deployment\nfrom ocp_resources.service import Service\nfrom ocp_resources.exceptions import ValidationError\n\nfor resource_cls in [Pod, Deployment, Service]:\n try:\n resource_cls.validate_dict({\"apiVersion\": \"v1\", \"kind\": resource_cls.kind, \"metadata\": {\"name\": \"warmup\"}})\n except ValidationError:\n pass # Expected \u2014 we just want to load the schemas\n```\n\n### Disabling Validation for Bulk Operations\n\nWhen creating many resources in a loop, disable auto-validation to avoid repeated checks if you've already validated your templates:\n\n```python\n# Validate the template once\nPod.validate_dict(resource_dict=pod_template)\n\n# Then create many instances without per-instance validation\nfor i in range(100):\n pod = Pod(\n client=client,\n name=f\"worker-{i}\",\n namespace=\"default\",\n containers=[{\"name\": \"app\", \"image\": \"myapp:latest\"}],\n schema_validation_enabled=False, # Skip \u2014 already validated\n )\n pod.deploy()\n```\n\n## Troubleshooting\n\n### Reading Validation Error Messages\n\nValidation errors include three key pieces of information:\n\n```\nResource validation failed for Pod/my-pod\n Field: spec.containers[0].image\n Error: 123 is not of type 'string'\n Expected type: string\n```\n\n- **Resource identifier** \u2014 which resource failed (kind and name)\n- **Field path** \u2014 the JSON path to the problematic field\n- **Error details** \u2014 what went wrong and what was expected\n\n### Common Validation Error Patterns\n\n| Error message pattern | Cause | Fix |\n|----------------------------------------------|-------------------------------------|--------------------------------------------------|\n| `'X' is a required property` | Missing a required field | Add the missing field to your resource definition |\n| `'X' is not of type 'Y'` | Wrong data type | Use the correct type (e.g., `3` not `\"3\"`) |\n| `'X' does not match pattern` | Invalid format | Follow Kubernetes naming rules (lowercase, alphanumeric, hyphens) |\n| `'X' is not one of [...]` | Invalid enum value | Use one of the allowed values listed in the error |\n| `Additional properties are not allowed` | Unknown field name | Check for typos in field names |\n\n### Validation Passes But API Rejects the Resource\n\nOpenAPI schema validation catches structural issues (wrong types, missing fields, invalid formats) but cannot check everything the API server validates. For example:\n\n- Unique name constraints within a namespace\n- Resource quota limits\n- Admission webhook rules\n- Cross-resource references (e.g., a ConfigMap that doesn't exist)\n\nIf validation passes but the API still rejects your resource, the issue is likely a server-side constraint.\n\n### No Schema Available for a Resource\n\nIf no schema is bundled for a resource kind, `validate()` silently succeeds instead of raising an error. A debug-level log message is emitted:\n\n```\nNo schema found for MyCustomResource, skipping validation\n```\n\nTo update bundled schemas, see [class-generator CLI Reference](class-generator-cli.html).\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Schema Validation and Code Generation Architecture](schema-validation-internals.html)\n- [Error Handling and Exception Reference](error-handling.html)\n- [class-generator CLI Reference](class-generator-cli.html)\n- [Resource and NamespacedResource API](resource-api.html)\n"}, {"slug": "managing-resource-lists", "title": "Managing Bulk Resources with ResourceList", "content": "# Managing Bulk Resources with ResourceList\n\nWhen you need to create several copies of the same resource or deploy a resource across multiple namespaces, doing it one at a time is tedious and error-prone. `ResourceList` and `NamespacedResourceList` let you create, deploy, and clean up groups of resources in a single operation.\n\n## Prerequisites\n\n- A working cluster connection (see [Connecting to Clusters](connecting-to-clusters.html))\n- Familiarity with creating individual resources (see [Creating and Managing Resources](creating-and-managing-resources.html))\n\n## Quick Example\n\nCreate three namespaces and deploy them in two lines:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import ResourceList, get_client\n\nclient = get_client()\n\nnamespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name=\"my-ns\")\nnamespaces.deploy()\n# Creates: my-ns-1, my-ns-2, my-ns-3\n```\n\nOr use a context manager for automatic cleanup:\n\n```python\nwith ResourceList(client=client, resource_class=Namespace, name=\"my-ns\", num_resources=3) as namespaces:\n print(namespaces[0].name) # \"my-ns-1\"\n# All three namespaces are deleted when the block exits\n```\n\n## Creating Multiple Copies of a Resource\n\n`ResourceList` creates N copies of a resource type, each with an auto-generated name based on a base name you provide.\n\n**Step 1:** Choose a resource class and specify how many copies you want:\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import ResourceList, get_client\n\nclient = get_client()\nnamespaces = ResourceList(\n resource_class=Namespace,\n num_resources=3,\n client=client,\n name=\"test-ns\",\n)\n```\n\nThis creates three `Namespace` objects named `test-ns-1`, `test-ns-2`, and `test-ns-3`. They are not yet deployed to the cluster.\n\n**Step 2:** Deploy all resources:\n\n```python\nnamespaces.deploy()\n```\n\n**Step 3:** Work with individual resources by index or iteration:\n\n```python\n# Access by index\nfirst_ns = namespaces[0]\nprint(first_ns.name) # \"test-ns-1\"\n\n# Iterate over all resources\nfor ns in namespaces:\n print(ns.name)\n\n# Check how many resources are in the list\nprint(len(namespaces)) # 3\n```\n\n**Step 4:** Clean up all resources when done:\n\n```python\nnamespaces.clean_up()\n```\n\n> **Note:** `clean_up()` deletes resources in reverse order. This ensures dependent resources are removed before the resources they depend on.\n\n## Deploying One Resource Per Namespace\n\n`NamespacedResourceList` creates one instance of a namespaced resource in each namespace from a `ResourceList`.\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import NamespacedResourceList, ResourceList, get_client\n\nclient = get_client()\n\n# First, create the namespaces\nnamespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name=\"app-ns\")\nnamespaces.deploy()\n\n# Then create one pod in each namespace\npods = NamespacedResourceList(\n client=client,\n resource_class=Pod,\n namespaces=namespaces,\n name=\"web-server\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n)\npods.deploy()\n```\n\nThis creates three pods, all named `web-server`, one in each namespace (`app-ns-1`, `app-ns-2`, `app-ns-3`).\n\n```python\nfor pod in pods:\n print(f\"{pod.name} in {pod.namespace}\")\n# web-server in app-ns-1\n# web-server in app-ns-2\n# web-server in app-ns-3\n```\n\n> **Warning:** The `namespaces` parameter must be a `ResourceList` containing only Namespace resources. Passing any other resource type raises a `TypeError`.\n\n## Passing Extra Resource Parameters\n\nAny additional keyword arguments you pass are forwarded to each resource's constructor. This lets you configure resources beyond just the name:\n\n```python\nfrom ocp_resources.role import Role\nfrom ocp_resources.resource import NamespacedResourceList\n\nroles = NamespacedResourceList(\n client=client,\n resource_class=Role,\n namespaces=namespaces,\n name=\"viewer-role\",\n rules=[\n {\n \"apiGroups\": [\"\"],\n \"resources\": [\"pods\"],\n \"verbs\": [\"get\", \"list\", \"watch\"],\n }\n ],\n)\nroles.deploy()\n```\n\n## Advanced Usage\n\n### Context Managers for Automatic Lifecycle\n\nBoth `ResourceList` and `NamespacedResourceList` support context managers. Resources are deployed on entry and cleaned up on exit \u2014 ideal for tests:\n\n```python\nwith ResourceList(client=client, resource_class=Namespace, name=\"test-ns\", num_resources=3) as namespaces:\n with NamespacedResourceList(\n client=client,\n resource_class=Pod,\n namespaces=namespaces,\n name=\"test-pod\",\n containers=[{\"name\": \"app\", \"image\": \"busybox\"}],\n ) as pods:\n # All namespaces and pods are deployed\n assert len(pods) == 3\n # Pods are cleaned up here\n# Namespaces are cleaned up here\n```\n\n> **Tip:** Nest the context managers so that namespaced resources are cleaned up before their namespaces are deleted.\n\n### Waiting During Deploy and Cleanup\n\nPass `wait=True` to `deploy()` to wait for each resource to reach a ready state. By default, `clean_up()` already waits for deletion to complete.\n\n```python\nnamespaces = ResourceList(resource_class=Namespace, num_resources=2, client=client, name=\"ns\")\nnamespaces.deploy(wait=True)\n\n# Later...\nnamespaces.clean_up(wait=True) # wait=True is the default\nnamespaces.clean_up(wait=False) # Skip waiting for deletion\n```\n\nFor more on waiting for specific conditions, see [Waiting for Resource Conditions and Status](waiting-for-conditions.html).\n\n### Comparison: ResourceList vs NamespacedResourceList\n\n| Feature | `ResourceList` | `NamespacedResourceList` |\n|---|---|---|\n| **Purpose** | Create N copies of any resource | Create one resource per namespace |\n| **Naming** | Auto-generates `{name}-1`, `{name}-2`, \u2026 | Same name in each namespace |\n| **Required params** | `resource_class`, `num_resources`, `name` | `resource_class`, `namespaces`, `name` |\n| **Use case** | Multiple namespaces, nodes, cluster-scoped resources | Pods, services, roles \u2014 anything namespace-scoped |\n| **Context manager** | \u2705 | \u2705 |\n\n## Troubleshooting\n\n**TypeError: \"All the resources in namespaces should be namespaces\"**\n\nYou passed a `ResourceList` that contains non-Namespace resources to `NamespacedResourceList`. Make sure the `namespaces` parameter is a `ResourceList` created with `resource_class=Namespace`.\n\n**Resources not appearing on the cluster**\n\nCreating a `ResourceList` or `NamespacedResourceList` only instantiates the Python objects \u2014 it does not deploy them. Call `.deploy()` or use a context manager (`with ... as`) to actually create the resources on the cluster.\n\n**Cleanup order issues**\n\nAlways clean up namespaced resources before their namespaces. When using context managers, nest them so the inner block (namespaced resources) exits first. When calling `clean_up()` manually, clean up dependent resources before their parents.\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html)\n- [Common Resource Patterns](common-patterns.html)\n- [Resource and NamespacedResource API](resource-api.html)\n"}, {"slug": "editing-resources-temporarily", "title": "Editing Resources Temporarily with ResourceEditor", "content": "# Editing Resources Temporarily with ResourceEditor\n\nTemporarily modify Kubernetes and OpenShift resources during tests and have their original values automatically restored when you're done. This is essential for test isolation\u2014ensuring that changes made during a test don't leak into other tests or persist in the cluster.\n\n## Prerequisites\n\n- `openshift-python-wrapper` installed in your project (see [Installing and Creating Your First Resource](quickstart.html))\n- A cluster connection established with a privileged client (see [Connecting to Clusters](connecting-to-clusters.html))\n- One or more existing resources on the cluster that you want to temporarily modify\n\n> **Warning:** The client used to retrieve resources must have sufficient privileges. Do not use an unprivileged user client with `ResourceEditor`.\n\n## Quick Example\n\n```python\nfrom ocp_resources.resource import ResourceEditor\nfrom ocp_resources.node import Node\n\nnode = Node(name=\"worker-0\")\n\nwith ResourceEditor(patches={node: {\"metadata\": {\"labels\": {\"test-label\": \"true\"}}}}):\n # The node now has the label \"test-label\": \"true\"\n # Run your test logic here...\n pass\n\n# The label is automatically removed when the block exits\n```\n\nThat's it\u2014when the `with` block ends (even if an exception occurs), the original resource state is restored.\n\n## Step-by-Step: Using ResourceEditor as a Context Manager\n\n### 1. Import and identify your target resource\n\n```python\nfrom ocp_resources.resource import ResourceEditor\nfrom ocp_resources.config_map import ConfigMap\n\ncm = ConfigMap(name=\"my-config\", namespace=\"my-namespace\")\n```\n\n### 2. Define your patch\n\nPatches are dictionaries that mirror the resource's YAML structure. Only include the fields you want to change:\n\n```python\npatch = {\"data\": {\"feature_flag\": \"enabled\", \"log_level\": \"debug\"}}\n```\n\n### 3. Wrap your test logic in a `with` block\n\n```python\nwith ResourceEditor(patches={cm: patch}):\n # ConfigMap now has updated data fields\n assert cm.instance.data.feature_flag == \"enabled\"\n # Run tests that depend on the modified config...\n```\n\n### 4. Original values are restored automatically\n\nWhen the `with` block exits, the original `data` values are written back to the resource. Fields that didn't exist before the patch are removed (set to `None` in the restore payload, which tells the API to delete them).\n\n## Patching Multiple Resources at Once\n\nPass multiple resource-patch pairs in a single `ResourceEditor`:\n\n```python\nfrom ocp_resources.resource import ResourceEditor\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.config_map import ConfigMap\n\nns = Namespace(name=\"test-ns\")\ncm = ConfigMap(name=\"app-config\", namespace=\"test-ns\")\n\npatches = {\n ns: {\"metadata\": {\"labels\": {\"env\": \"test\"}}},\n cm: {\"data\": {\"database_url\": \"postgres://test-db:5432\"}},\n}\n\nwith ResourceEditor(patches=patches):\n # Both resources are patched; run tests here\n pass\n# Both resources are restored to their original state\n```\n\n## Using update() and restore() Without a Context Manager\n\nIf you need finer control over when patches are applied and reverted\u2014for example, in `setup`/`teardown` fixtures\u2014use the `update()` and `restore()` methods directly:\n\n```python\nfrom ocp_resources.resource import ResourceEditor\nfrom ocp_resources.config_map import ConfigMap\n\ncm = ConfigMap(name=\"app-config\", namespace=\"default\")\n\neditor = ResourceEditor(patches={cm: {\"data\": {\"mode\": \"maintenance\"}}})\n\n# Apply the patch and create backups\neditor.update(backup_resources=True)\n\n# ... run tests ...\n\n# Restore original values\neditor.restore()\n```\n\n> **Note:** When calling `update()` manually, pass `backup_resources=True` to ensure original values are captured. Without this flag, no backup is created and `restore()` will have nothing to revert.\n\n## Advanced Usage\n\n### Replace Instead of Patch\n\nBy default, `ResourceEditor` uses the `\"update\"` action, which sends a merge-patch (`application/merge-patch+json`). This merges your changes into the existing resource. If you need to *replace* the entire resource (for example, to remove fields that merge-patch can't delete), use `action=\"replace\"`:\n\n```python\nfrom ocp_resources.resource import ResourceEditor\n\nwith ResourceEditor(\n patches={my_resource: {\"spec\": {\"replicas\": 3}}},\n action=\"replace\",\n):\n # The resource is fully replaced, not merged\n pass\n```\n\n| Action | Method Used | Behavior |\n|---|---|---|\n| `\"update\"` (default) | Merge patch | Merges patch into existing resource; cannot remove fields by omission |\n| `\"replace\"` | Full replacement | Replaces the entire resource; fields not in the patch are removed |\n\n> **Warning:** With `action=\"replace\"`, the patch must include all required fields for the resource, not just the fields you want to change. The replacement payload automatically includes `metadata.name`, `metadata.namespace`, `metadata.resourceVersion`, `kind`, and `apiVersion`.\n\n### Providing Your Own Backups\n\nIf you already know what the restore state should be (or need custom restore logic), pass `user_backups` to skip the automatic backup calculation:\n\n```python\nfrom ocp_resources.resource import ResourceEditor\n\ncustom_backup = {my_resource: {\"spec\": {\"replicas\": 1}}}\n\nwith ResourceEditor(\n patches={my_resource: {\"spec\": {\"replicas\": 5}}},\n user_backups=custom_backup,\n):\n # Resource scaled to 5 replicas\n pass\n# Restored to 1 replica (your custom backup), regardless of what the original value was\n```\n\n### Inspecting Backups\n\nAccess the automatically generated backup data through the `backups` property:\n\n```python\neditor = ResourceEditor(patches={my_resource: {\"metadata\": {\"labels\": {\"env\": \"staging\"}}}})\neditor.update(backup_resources=True)\n\n# See what was backed up\nprint(editor.backups)\n# {: {'metadata': {'labels': {'env': 'production'}}}}\n\neditor.restore()\n```\n\n### No-Op Detection\n\n`ResourceEditor` automatically detects when a patch produces no actual changes. If the patch values already match the resource's current state, the resource is skipped entirely and a warning is logged:\n\n```\nResourceEdit: no diff found in patch for my-resource -- skipping\n```\n\nThis prevents unnecessary API calls and avoids triggering watch events for no-op changes.\n\n### Automatic Retry on Conflicts\n\nAll patch and restore operations are wrapped with automatic retry logic. If a `ConflictError` occurs (for example, due to a concurrent update changing the `resourceVersion`), the operation is retried automatically with a 5-second sleep interval and a 30-second timeout.\n\n## API Reference\n\n### Constructor\n\n```python\nResourceEditor(\n patches: dict,\n action: str = \"update\",\n user_backups: dict | None = None,\n)\n```\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `patches` | `dict` | *(required)* | Mapping of resource objects to patch dicts: `{resource: {yaml_patch}}` |\n| `action` | `str` | `\"update\"` | Either `\"update\"` (merge patch) or `\"replace\"` (full replacement) |\n| `user_backups` | `dict \\| None` | `None` | Custom backup dicts to use for restore instead of auto-generated ones |\n\n### Properties\n\n| Property | Return Type | Description |\n|---|---|---|\n| `backups` | `dict` | The backup data captured for each patched resource: `{resource: backup_dict}` |\n| `patches` | `dict` | The patches dict provided in the constructor |\n\n### Methods\n\n| Method | Parameters | Description |\n|---|---|---|\n| `update(backup_resources=False)` | `backup_resources: bool` | Apply patches. Set `backup_resources=True` to capture original values for later restore. |\n| `restore()` | \u2014 | Re-apply the stored backup dicts to revert all patched resources. |\n\n### Context Manager Protocol\n\n| Method | Behavior |\n|---|---|\n| `__enter__()` | Calls `update(backup_resources=True)` and returns the `ResourceEditor` instance |\n| `__exit__()` | Calls `restore()` to revert all changes |\n\n## Troubleshooting\n\n| Problem | Cause | Solution |\n|---|---|---|\n| Restore doesn't revert changes | Called `update()` without `backup_resources=True` | Always pass `backup_resources=True` when calling `update()` manually, or use the context manager (`with` block) which does this automatically |\n| `NotFoundError` during backup | Resource cannot be found by name (known with some CRDs like `ServiceMonitor`) | `ResourceEditor` handles this automatically by falling back to a field-selector query |\n| `ConflictError` during patch | Another process modified the resource concurrently | Automatic retries handle this; if it persists beyond 30 seconds, check for controllers continuously modifying the resource |\n| Patch has no effect | The resource already has the values in your patch | This is expected\u2014`ResourceEditor` logs a warning and skips the resource. Check that you're patching the correct fields. |\n| `replace` action fails | Patch is missing required fields | When using `action=\"replace\"`, provide all required spec fields since the entire resource is replaced |\n\n> **Tip:** For creating and cleaning up resources (rather than editing existing ones), see [Creating and Managing Resources](creating-and-managing-resources.html). For managing groups of similar resources, see [Managing Bulk Resources with ResourceList](managing-resource-lists.html).\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Resource and NamespacedResource API](resource-api.html)\n- [Common Resource Patterns](common-patterns.html)\n- [Managing Bulk Resources with ResourceList](managing-resource-lists.html)\n- [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html)\n"}, {"slug": "working-with-virtual-machines", "title": "Working with Virtual Machines (KubeVirt)", "content": "# Working with Virtual Machines (KubeVirt)\n\nManage the full lifecycle of KubeVirt virtual machines on OpenShift \u2014 create VMs, start and stop them, live-migrate between nodes, take snapshots, and interact with their running instances \u2014 all from Python.\n\n## Prerequisites\n\n- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html))\n- A cluster connection configured (see [Connecting to Clusters](connecting-to-clusters.html))\n- KubeVirt / OpenShift Virtualization installed on your cluster\n\n## Quick Example\n\n```python\nfrom ocp_resources.virtual_machine import VirtualMachine\n\nvm = VirtualMachine(\n name=\"my-vm\",\n namespace=\"my-namespace\",\n body={\n \"spec\": {\n \"domain\": {\n \"devices\": {\"disks\": [{\"disk\": {\"bus\": \"virtio\"}, \"name\": \"rootdisk\"}]},\n \"resources\": {\"requests\": {\"memory\": \"1Gi\"}},\n },\n \"volumes\": [{\"name\": \"rootdisk\", \"containerDisk\": {\"image\": \"quay.io/containerdisks/fedora\"}}],\n }\n },\n)\n\nwith vm:\n vm.start(wait=True)\n print(f\"VM ready: {vm.ready}\") # True\n print(f\"Status: {vm.printable_status}\") # \"Running\"\n vm.stop(wait=True)\n# VM is automatically cleaned up when the context manager exits\n```\n\n## Creating a VirtualMachine\n\n```python\nfrom ocp_resources.virtual_machine import VirtualMachine\n\nvm = VirtualMachine(\n name=\"my-vm\",\n namespace=\"my-namespace\",\n body={\n \"spec\": {\n \"domain\": {\n \"devices\": {\"disks\": [{\"disk\": {\"bus\": \"virtio\"}, \"name\": \"rootdisk\"}]},\n \"resources\": {\"requests\": {\"memory\": \"2Gi\"}},\n },\n \"volumes\": [\n {\n \"name\": \"rootdisk\",\n \"containerDisk\": {\"image\": \"quay.io/containerdisks/fedora\"},\n }\n ],\n }\n },\n)\nvm.deploy()\n```\n\nThe `body` parameter accepts a dictionary that maps to the KubeVirt VM `spec.template` structure. If you omit `body`, a minimal skeleton (`{\"template\": {\"spec\": {}}}`) is created automatically.\n\nYou can also create a VM from a YAML file:\n\n```python\nvm = VirtualMachine(\n name=\"my-vm\",\n namespace=\"my-namespace\",\n yaml_file=\"vm-definition.yaml\",\n)\nvm.deploy()\n```\n\n### Constructor Parameters\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `name` | `str` | `None` | Name of the VirtualMachine resource |\n| `namespace` | `str` | `None` | Target namespace |\n| `client` | `DynamicClient` | `None` | Kubernetes client; auto-resolved if omitted |\n| `body` | `dict` | `None` | VM spec body dictionary |\n| `teardown` | `bool` | `True` | Whether to delete the resource on cleanup |\n| `yaml_file` | `str` | `None` | Path to a YAML file defining the VM |\n| `delete_timeout` | `int` | `240` (seconds) | Timeout for delete operations |\n\n> **Tip:** Use the context manager (`with vm:`) to ensure automatic cleanup. See [Creating and Managing Resources](creating-and-managing-resources.html) for details on `deploy()`, `clean_up()`, and context managers.\n\n## Starting, Stopping, and Restarting\n\n### Start a VM\n\n```python\n# Fire-and-forget\nvm.start()\n\n# Wait until the VM is ready\nvm.start(wait=True)\n\n# Custom timeout (seconds)\nvm.start(timeout=600, wait=True)\n```\n\n### Stop a VM\n\n```python\n# Fire-and-forget\nvm.stop()\n\n# Wait for the VM to stop and the VMI to be deleted\nvm.stop(wait=True)\n\n# Custom timeouts\nvm.stop(timeout=300, vmi_delete_timeout=300, wait=True)\n```\n\n### Restart a VM\n\n```python\nvm.restart(wait=True)\n```\n\nWhen `wait=True`, `restart()` waits for the old virt-launcher pod to be deleted and the new VMI to reach the `Running` state.\n\n## Checking VM Status\n\n```python\n# Boolean ready status: True if running, None if stopped\nvm.ready # True\n\n# Human-readable status string\nvm.printable_status # \"Running\", \"Stopped\", \"Starting\", \"Migrating\", etc.\n\n# Wait for ready status\nvm.wait_for_ready_status(status=True, timeout=300) # Wait for running\nvm.wait_for_ready_status(status=None, timeout=300) # Wait for stopped\n\n# Wait for a specific status field to clear\nvm.wait_for_status_none(status=\"snapshotInProgress\")\n```\n\n### Run Strategy Constants\n\nUse built-in constants for the `runStrategy` field:\n\n```python\nVirtualMachine.RunStrategy.ALWAYS # \"Always\"\nVirtualMachine.RunStrategy.HALTED # \"Halted\"\nVirtualMachine.RunStrategy.MANUAL # \"Manual\"\nVirtualMachine.RunStrategy.RERUNONFAILURE # \"RerunOnFailure\"\n```\n\n### Status Constants\n\n```python\nVirtualMachine.Status.STARTING # \"Starting\"\nVirtualMachine.Status.RUNNING # \"Running\" (inherited)\nVirtualMachine.Status.STOPPED # \"Stopped\"\nVirtualMachine.Status.STOPPING # \"Stopping\"\nVirtualMachine.Status.MIGRATING # \"Migrating\"\nVirtualMachine.Status.PAUSED # \"Paused\"\nVirtualMachine.Status.PROVISIONING # \"Provisioning\"\nVirtualMachine.Status.ERROR_UNSCHEDULABLE # \"ErrorUnschedulable\"\nVirtualMachine.Status.DATAVOLUME_ERROR # \"DataVolumeError\"\n```\n\n## Working with VirtualMachineInstances\n\nEvery running VM has an associated `VirtualMachineInstance` (VMI). Access it through the VM or create one directly.\n\n### Accessing the VMI from a VM\n\n```python\nvmi = vm.vmi # Returns a VirtualMachineInstance object\n```\n\n### Creating a Standalone VMI\n\n```python\nfrom ocp_resources.virtual_machine_instance import VirtualMachineInstance\n\nvmi = VirtualMachineInstance(\n name=\"my-vmi\",\n namespace=\"my-namespace\",\n domain={\n \"devices\": {\"disks\": [{\"disk\": {\"bus\": \"virtio\"}, \"name\": \"rootdisk\"}]},\n \"resources\": {\"requests\": {\"memory\": \"1Gi\"}},\n },\n volumes=[{\"name\": \"rootdisk\", \"containerDisk\": {\"image\": \"quay.io/containerdisks/fedora\"}}],\n)\nvmi.deploy()\n```\n\n> **Note:** The `domain` parameter is **required** when creating a VMI programmatically. Omitting it raises `MissingRequiredArgumentError`.\n\n### VMI Constructor Parameters\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `domain` | `dict` | **Yes** | Domain spec (CPU, memory, devices) |\n| `volumes` | `list` | No | Volumes to mount |\n| `networks` | `list` | No | Network interfaces |\n| `affinity` | `dict` | No | Scheduling affinity rules |\n| `node_selector` | `dict` | No | Node label selector |\n| `tolerations` | `list` | No | Toleration rules |\n| `eviction_strategy` | `str` | No | `\"LiveMigrate\"`, `\"LiveMigrateIfPossible\"`, `\"None\"`, or `\"External\"` |\n| `termination_grace_period_seconds` | `int` | No | Seconds to wait before force-killing |\n| `hostname` | `str` | No | VM hostname |\n| `priority_class_name` | `str` | No | Pod priority class |\n| `scheduler_name` | `str` | No | Custom scheduler name |\n| `start_strategy` | `str` | No | Set to `\"Paused\"` to start paused |\n| `access_credentials` | `list` | No | Public keys to inject |\n| `topology_spread_constraints` | `list` | No | Topology spread rules |\n\n### Waiting for a VMI to Run\n\n```python\nvmi.wait_until_running(timeout=300)\n```\n\nIf the VMI fails to start, `wait_until_running` raises `TimeoutExpiredError` and logs the virt-launcher pod status and logs for debugging. Set `logs=False` to suppress this:\n\n```python\nvmi.wait_until_running(timeout=300, logs=False)\n```\n\nYou can also specify a `stop_status` to abort early if the VMI enters a failure state:\n\n```python\nvmi.wait_until_running(timeout=300, stop_status=\"Failed\")\n```\n\n### Pausing and Unpausing\n\n```python\nvmi.pause(wait=True) # Pause the VMI\nvmi.unpause(wait=True) # Resume the VMI\n```\n\n### Resetting a VMI\n\n```python\nvmi.reset() # Hard-reset the VMI (like pressing the reset button)\n```\n\n### Getting the VMI Node\n\n```python\nnode = vmi.get_node()\nprint(node.name)\n```\n\n### Network Interfaces\n\n```python\n# All interfaces from the VMI status\ninterfaces = vmi.interfaces\n\n# Get IP address for a specific interface\nip = vmi.interface_ip(\"eth0\")\n```\n\n### Guest Agent Information\n\nWhen the QEMU guest agent is installed in the VM:\n\n```python\nvmi.guest_os_info # OS information\nvmi.guest_fs_info # Filesystem information\nvmi.guest_user_info # Logged-in user information\nvmi.os_version # OS version string\n```\n\n> **Note:** If no guest agent is installed, `os_version` returns an empty dict and logs a warning.\n\n### Accessing the Virt-Launcher Pod\n\n```python\npod = vmi.get_virt_launcher_pod()\nprint(pod.name)\nprint(pod.status)\n\n# Access logs from the compute container\nlogs = pod.log(container=\"compute\")\n```\n\nFor clusters where a privileged client is required:\n\n```python\npod = vmi.get_virt_launcher_pod(privileged_client=admin_client)\n```\n\n### Accessing the Virt-Handler Pod\n\n```python\nhandler_pod = vmi.get_virt_handler_pod(privileged_client=admin_client)\n```\n\n### Executing Virsh Commands\n\nRun virsh commands inside the virt-launcher pod:\n\n```python\n# Get the XML definition\nxml_output = vmi.get_xml()\n\n# Get the XML as a parsed Python dict\nxml_dict = vmi.get_xml_dict()\n\n# Get domain memory stats\nmemstats = vmi.get_dommemstat()\n\n# Run any arbitrary virsh command\noutput = vmi.execute_virsh_command(command=\"dominfo\")\n```\n\n### Checking Root Status\n\n```python\npod = vmi.get_virt_launcher_pod()\n\n# Check if the virt-launcher pod runs as root\nVirtualMachineInstance.is_pod_root(pod=pod) # True or False\n\n# Get the pod's runAsUser UID\nVirtualMachineInstance.get_pod_user_uid(pod=pod) # int or None\n```\n\n## Live Migration\n\nTrigger a live migration by creating a `VirtualMachineInstanceMigration`:\n\n```python\nfrom ocp_resources.virtual_machine_instance_migration import VirtualMachineInstanceMigration\n\nmigration = VirtualMachineInstanceMigration(\n name=\"migrate-my-vm\",\n namespace=\"my-namespace\",\n vmi_name=\"my-vm\",\n)\nmigration.deploy()\n```\n\nMonitor migration by checking the VMI's status. Once migration completes, the VMI runs on the target node. See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for condition-based waiting.\n\n### Migration Resource Quotas\n\nControl resource allocation for migrations:\n\n```python\nfrom ocp_resources.virtual_machine_migration_resource_quota import VirtualMachineMigrationResourceQuota\n\nquota = VirtualMachineMigrationResourceQuota(\n name=\"migration-quota\",\n namespace=\"my-namespace\",\n requests_cpu=\"2\",\n requests_memory=\"4Gi\",\n limits_cpu=\"4\",\n limits_memory=\"8Gi\",\n)\nquota.deploy()\n```\n\n## Snapshots and Restores\n\n### Taking a Snapshot\n\n```python\nfrom ocp_resources.virtual_machine_snapshot import VirtualMachineSnapshot\n\nsnapshot = VirtualMachineSnapshot(\n name=\"my-vm-snapshot\",\n namespace=\"my-namespace\",\n vm_name=\"my-vm\",\n)\nsnapshot.deploy()\n\n# Wait for the snapshot to be ready\nsnapshot.wait_ready_to_use(timeout=300)\n\n# Wait for both snapshot readiness AND the VM's snapshotInProgress to clear\nsnapshot.wait_snapshot_done(timeout=300)\n```\n\n### Restoring from a Snapshot\n\n```python\nfrom ocp_resources.virtual_machine_restore import VirtualMachineRestore\n\nrestore = VirtualMachineRestore(\n name=\"my-vm-restore\",\n namespace=\"my-namespace\",\n vm_name=\"my-vm\",\n snapshot_name=\"my-vm-snapshot\",\n)\nrestore.deploy()\n\n# Wait for restore to complete\nrestore.wait_restore_done(timeout=300)\n```\n\nThe `wait_restore_done()` method checks both the restore's `complete` status and that the VM's `restoreInProgress` field is cleared.\n\n| Parameter | Type | Description |\n|---|---|---|\n| `vm_name` | `str` | Target VirtualMachine name |\n| `snapshot_name` | `str` | Source VirtualMachineSnapshot name |\n| `volume_restore_policy` | `str` | Optional policy for volume restoration |\n\n## Cloning VMs\n\n```python\nfrom ocp_resources.virtual_machine_clone import VirtualMachineClone\n\nclone = VirtualMachineClone(\n name=\"clone-my-vm\",\n namespace=\"my-namespace\",\n source_name=\"my-vm\",\n target_name=\"my-vm-clone\",\n)\nclone.deploy()\n```\n\n> **Note:** `source_name` is **required**. If omitted, `MissingRequiredArgumentError` is raised.\n\n### Clone Parameters\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `source_name` | `str` | **Required** | Name of the source VM |\n| `source_kind` | `str` | `\"VirtualMachine\"` | Kind of the source resource |\n| `target_name` | `str` | Auto-generated | Name for the cloned VM |\n| `label_filters` | `list` | `None` | Labels to copy, e.g. `[\"*\", \"!someKey/*\"]` |\n| `annotation_filters` | `list` | `None` | Annotations to copy |\n| `new_mac_addresses` | `dict` | `None` | Override MACs: `{\"nic0\": \"00:11:22:33:44:55\"}` |\n| `new_smbios_serial` | `str` | `None` | Override SMBIOS serial number |\n| `volume_name_policy` | `str` | `None` | Volume naming policy for the clone |\n\n## Exporting VMs\n\n```python\nfrom ocp_resources.virtual_machine_export import VirtualMachineExport\n\nexport = VirtualMachineExport(\n name=\"export-my-vm\",\n namespace=\"my-namespace\",\n source={\"apiGroup\": \"kubevirt.io\", \"kind\": \"VirtualMachine\", \"name\": \"my-vm\"},\n token_secret_ref=\"my-export-token\",\n ttl_duration=\"1h\",\n)\nexport.deploy()\n```\n\n> **Note:** The `source` parameter is **required**. If omitted, `MissingRequiredArgumentError` is raised.\n\n## Advanced Usage\n\n### Instance Types and Preferences\n\nKubeVirt instance types define reusable VM sizing profiles. Preferences define non-sizing defaults (clock, firmware, devices).\n\n#### Namespaced Instance Type\n\n```python\nfrom ocp_resources.virtual_machine_instancetype import VirtualMachineInstancetype\n\ninstancetype = VirtualMachineInstancetype(\n name=\"small\",\n namespace=\"my-namespace\",\n cpu={\"guest\": 2},\n memory={\"guest\": \"4Gi\"},\n)\ninstancetype.deploy()\n```\n\nBoth `cpu` and `memory` are **required**. Omitting either raises `MissingRequiredArgumentError`.\n\n#### Cluster-Scoped Instance Type\n\n```python\nfrom ocp_resources.virtual_machine_cluster_instancetype import VirtualMachineClusterInstancetype\n\ncluster_instancetype = VirtualMachineClusterInstancetype(\n name=\"large\",\n cpu={\"guest\": 8},\n memory={\"guest\": \"16Gi\"},\n gpus=[{\"deviceName\": \"nvidia.com/A100\", \"name\": \"gpu0\"}],\n)\ncluster_instancetype.deploy()\n```\n\n#### Namespaced Preference\n\n```python\nfrom ocp_resources.virtual_machine_preference import VirtualMachinePreference\n\npreference = VirtualMachinePreference(\n name=\"linux-pref\",\n namespace=\"my-namespace\",\n cpu={\"preferredCPUTopology\": \"preferSockets\"},\n firmware={\"preferredUseEfi\": True},\n)\npreference.deploy()\n```\n\n#### Cluster-Scoped Preference\n\n```python\nfrom ocp_resources.virtual_machine_cluster_preference import VirtualMachineClusterPreference\n\ncluster_preference = VirtualMachineClusterPreference(\n name=\"windows-pref\",\n clock={\"preferredTimer\": {\"hpet\": {\"present\": False}}},\n features={\"preferredHyperv\": {\"spinlocks\": {\"spinlocks\": 8191}}},\n)\ncluster_preference.deploy()\n```\n\n### VMI Replica Sets\n\nScale out VMIs horizontally:\n\n```python\nfrom ocp_resources.virtual_machine_instance_replica_set import VirtualMachineInstanceReplicaSet\n\nreplica_set = VirtualMachineInstanceReplicaSet(\n name=\"my-vmi-rs\",\n namespace=\"my-namespace\",\n replicas=3,\n selector={\"matchLabels\": {\"app\": \"my-vmi\"}},\n template={\n \"metadata\": {\"labels\": {\"app\": \"my-vmi\"}},\n \"spec\": {\n \"domain\": {\n \"devices\": {},\n \"resources\": {\"requests\": {\"memory\": \"1Gi\"}},\n }\n },\n },\n)\nreplica_set.deploy()\n```\n\nBoth `selector` and `template` are **required**.\n\n### Storage Migration\n\nMigrate VM storage between storage classes:\n\n```python\nfrom ocp_resources.virtual_machine_storage_migration_plan import VirtualMachineStorageMigrationPlan\nfrom ocp_resources.virtual_machine_storage_migration import VirtualMachineStorageMigration\n\n# Create a migration plan\nplan = VirtualMachineStorageMigrationPlan(\n name=\"migrate-storage\",\n namespace=\"my-namespace\",\n virtual_machines=[{\"name\": \"my-vm\", \"volumes\": [{\"name\": \"rootdisk\", \"target\": {\"storageClassName\": \"fast-ssd\"}}]}],\n retention_policy=\"deleteSource\",\n)\nplan.deploy()\n\n# Execute the migration\nmigration = VirtualMachineStorageMigration(\n name=\"migrate-storage-exec\",\n namespace=\"my-namespace\",\n virtual_machine_storage_migration_plan_ref={\"name\": \"migrate-storage\", \"namespace\": \"my-namespace\"},\n)\nmigration.deploy()\n```\n\n### VM Templates\n\n```python\nfrom ocp_resources.virtual_machine_template import VirtualMachineTemplate\n\ntemplate = VirtualMachineTemplate(\n name=\"fedora-template\",\n namespace=\"my-namespace\",\n virtual_machine={\n \"spec\": {\n \"template\": {\n \"spec\": {\n \"domain\": {\n \"devices\": {},\n \"resources\": {\"requests\": {\"memory\": \"2Gi\"}},\n }\n }\n }\n }\n },\n parameters=[{\"name\": \"VM_NAME\", \"description\": \"Name of the VM\", \"required\": True}],\n message=\"Use this template to create Fedora VMs.\",\n)\ntemplate.deploy()\n```\n\n### Listing VMs Across Namespaces\n\n```python\n# List all VMs in a namespace\nfor vm in VirtualMachine.get(namespace=\"my-namespace\"):\n print(f\"{vm.name}: {vm.instance.get('status', {}).get('printableStatus')}\")\n\n# List with label selectors\nfor vm in VirtualMachine.get(\n namespace=\"my-namespace\",\n label_selector=\"app=webserver\",\n):\n print(vm.name)\n```\n\nSee [Querying and Watching Resources](querying-resources.html) for more query options.\n\n### Getting VM Network Interfaces\n\n```python\ninterfaces = vm.get_interfaces()\nfor iface in interfaces:\n print(f\"Name: {iface['name']}, Model: {iface.get('model', 'default')}\")\n```\n\n## Troubleshooting\n\n### VM Stuck in \"Starting\" Status\n\nCheck the VMI's virt-launcher pod for scheduling or image-pull issues:\n\n```python\nvmi = vm.vmi\ntry:\n pod = vmi.get_virt_launcher_pod()\n print(f\"Pod status: {pod.status}\")\n print(pod.log(container=\"compute\"))\nexcept ResourceNotFoundError:\n print(\"Virt-launcher pod not found \u2014 VM may not have been scheduled yet\")\n```\n\n### TimeoutExpiredError on start/stop\n\nIncrease the timeout parameter:\n\n```python\nvm.start(timeout=600, wait=True) # 10 minutes instead of default 4\n```\n\n### MissingRequiredArgumentError\n\nThis is raised when a required constructor parameter is missing. Common cases:\n\n| Resource | Required Parameters |\n|---|---|\n| `VirtualMachineInstance` | `domain` |\n| `VirtualMachineInstancetype` | `cpu`, `memory` |\n| `VirtualMachineClone` | `source_name` |\n| `VirtualMachineExport` | `source` |\n| `VirtualMachineInstanceReplicaSet` | `selector`, `template` |\n| `VirtualMachineStorageMigration` | `virtual_machine_storage_migration_plan_ref` |\n| `VirtualMachineTemplate` | `virtual_machine` |\n\n### Guest Agent Not Reporting\n\nIf `vmi.os_version` returns an empty dict, the QEMU guest agent is not installed or not running inside the VM. Install and start `qemu-guest-agent` in the guest OS.\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Querying and Watching Resources](querying-resources.html)\n- [Common Resource Patterns](common-patterns.html)\n- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html)\n"}, {"slug": "testing-without-cluster", "title": "Testing Without a Cluster Using the Fake Client", "content": "# Testing Without a Cluster Using the Fake Client\n\nWrite unit tests that create, read, update, and delete Kubernetes resources \u2014 including status checks, watch events, and custom CRDs \u2014 without connecting to a real cluster.\n\n## Prerequisites\n\n- `openshift-python-wrapper` installed (see [Installing and Creating Your First Resource](quickstart.html))\n- `pytest` installed for running tests\n\n## Quick Example\n\n```python\nimport pytest\nfrom ocp_resources.resource import get_client\nfrom ocp_resources.namespace import Namespace\n\n\n@pytest.fixture(scope=\"class\")\ndef fake_client():\n return get_client(fake=True)\n\n\nclass TestMyFeature:\n def test_create_namespace(self, fake_client):\n ns = Namespace(client=fake_client, name=\"my-namespace\")\n ns.deploy()\n\n assert ns.exists\n assert ns.kind == \"Namespace\"\n assert ns.status == Namespace.Status.ACTIVE\n\n ns.clean_up(wait=False)\n assert not ns.exists\n```\n\nRun it with `pytest` \u2014 no cluster, no kubeconfig, no network calls.\n\n## How It Works\n\nCalling `get_client(fake=True)` returns an in-memory client that simulates the Kubernetes API. It supports all standard resources (Pods, Deployments, Services, Namespaces, etc.) and OpenShift resources (Routes, Projects, etc.) out of the box.\n\nAll resource classes (`Pod`, `Deployment`, `Namespace`, etc.) accept this fake client through their `client` parameter, and all CRUD methods work identically to a real cluster.\n\n## Step-by-Step: CRUD Operations\n\n### 1. Set up a shared fixture\n\nCreate a `conftest.py` in your test directory:\n\n```python\nimport pytest\nfrom ocp_resources.resource import get_client\n\n\n@pytest.fixture(scope=\"class\")\ndef fake_client():\n \"\"\"Provides a fake Kubernetes client for all tests.\"\"\"\n return get_client(fake=True)\n```\n\n> **Tip:** Use `scope=\"class\"` so resources created in one test method persist for subsequent methods in the same class.\n\n### 2. Create a resource\n\n```python\nfrom ocp_resources.pod import Pod\n\n\ndef test_create_pod(fake_client):\n pod = Pod(\n client=fake_client,\n name=\"my-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n )\n result = pod.deploy()\n\n assert result.name == \"my-pod\"\n assert pod.exists\n```\n\nThe fake client automatically generates metadata (`uid`, `resourceVersion`, `creationTimestamp`) and a realistic `status` section for the resource.\n\n### 3. Read and query resources\n\n```python\nfrom ocp_resources.namespace import Namespace\n\n\ndef test_list_resources(fake_client):\n # Create some namespaces first\n Namespace(client=fake_client, name=\"ns-a\").deploy()\n Namespace(client=fake_client, name=\"ns-b\").deploy()\n\n # Iterate over all Namespace resources\n for ns in Namespace.get(client=fake_client):\n assert ns.name\n```\n\nAccess individual resource data via the `instance` property:\n\n```python\ndef test_get_instance(fake_client):\n ns = Namespace(client=fake_client, name=\"test-ns\")\n ns.deploy()\n\n assert ns.instance\n assert ns.kind == \"Namespace\"\n```\n\n### 4. Update a resource\n\nUse `update()` to patch a resource or `update_replace()` to replace it entirely:\n\n```python\ndef test_update_labels(fake_client):\n ns = Namespace(client=fake_client, name=\"labeled-ns\")\n ns.deploy()\n\n # Patch: add a label\n resource_dict = ns.instance.to_dict()\n resource_dict[\"metadata\"][\"labels\"][\"env\"] = \"staging\"\n ns.update(resource_dict=resource_dict)\n\n assert ns.labels[\"env\"] == \"staging\"\n```\n\n```python\ndef test_replace_labels(fake_client):\n ns = Namespace(client=fake_client, name=\"replace-ns\")\n ns.deploy()\n\n resource_dict = ns.instance.to_dict()\n resource_dict[\"metadata\"][\"labels\"] = {\"new-label\": \"value\"}\n ns.update_replace(resource_dict=resource_dict)\n\n assert \"new-label\" in ns.labels.keys()\n```\n\n### 5. Delete a resource\n\n```python\ndef test_delete_resource(fake_client):\n ns = Namespace(client=fake_client, name=\"temp-ns\")\n ns.deploy()\n assert ns.exists\n\n ns.clean_up(wait=False)\n assert not ns.exists\n```\n\n### 6. Use context managers for automatic cleanup\n\nResources support Python's `with` statement. The resource deploys on entry and is cleaned up on exit:\n\n```python\nfrom ocp_resources.secret import Secret\n\n\ndef test_context_manager(fake_client):\n with Secret(name=\"my-secret\", namespace=\"default\", client=fake_client) as sec:\n assert sec.exists\n\n # Automatically cleaned up\n assert not sec.exists\n```\n\n## Testing Status and Conditions\n\nThe fake client generates realistic status objects so you can test code that reads resource status.\n\n### Checking resource status\n\n```python\ndef test_namespace_status(fake_client):\n ns = Namespace(client=fake_client, name=\"status-ns\")\n ns.deploy()\n\n assert ns.status == Namespace.Status.ACTIVE\n```\n\n### Waiting for conditions\n\n```python\nfrom ocp_resources.pod import Pod\n\n\ndef test_wait_for_condition(fake_client):\n pod = Pod(\n client=fake_client,\n name=\"ready-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"app\", \"image\": \"nginx:latest\"}],\n )\n pod.deploy()\n\n pod.wait_for_status(status=Pod.Status.RUNNING, timeout=5)\n```\n\n### Simulating \"not ready\" resources\n\nControl a resource's readiness state with the `fake-client.io/ready` annotation:\n\n```python\ndef test_not_ready_pod(fake_client):\n pod = Pod(\n client=fake_client,\n name=\"failing-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"app\", \"image\": \"nginx:latest\"}],\n annotations={\"fake-client.io/ready\": \"false\"},\n )\n pod.deploy()\n\n # The pod will have Ready condition set to False\n pod.wait_for_condition(\n condition=pod.Condition.READY,\n status=pod.Condition.Status.FALSE,\n timeout=5,\n )\n\n message = pod.get_condition_message(\n condition_type=pod.Condition.READY,\n condition_status=pod.Condition.Status.FALSE,\n )\n assert message\n```\n\nResource-specific status behavior when marked \"not ready\":\n\n| Resource | Ready behavior | Not-ready behavior |\n|---|---|---|\n| Pod | `phase: Running`, containers ready | Containers in `waiting` state |\n| Deployment | `readyReplicas` matches `spec.replicas` | `readyReplicas: 0`, `unavailableReplicas` set |\n| Namespace | `phase: Active` | `phase: Terminating` |\n| All others | `Ready` condition `True` | `Ready` condition `False` |\n\n## Testing with Events\n\nThe fake client automatically generates Kubernetes events for create, update, and delete operations. You can query them through the resource's `events()` method:\n\n```python\ndef test_resource_events(fake_client):\n pod = Pod(\n client=fake_client,\n name=\"event-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n )\n pod.deploy()\n\n events = list(pod.events(timeout=1))\n assert events\n```\n\n## Testing with Deployments\n\nDeployments get a full status template including replica counts, conditions, and observed generation:\n\n```python\nfrom ocp_resources.deployment import Deployment\n\n\ndef test_deployment(fake_client):\n dep = Deployment(\n client=fake_client,\n name=\"my-deploy\",\n namespace=\"default\",\n replicas=3,\n selector={\"matchLabels\": {\"app\": \"web\"}},\n template={\n \"metadata\": {\"labels\": {\"app\": \"web\"}},\n \"spec\": {\"containers\": [{\"name\": \"web\", \"image\": \"nginx:latest\"}]},\n },\n )\n dep.deploy()\n\n assert dep.exists\n assert dep.kind == \"Deployment\"\n\n dep.clean_up(wait=False)\n assert not dep.exists\n```\n\n## Testing with ResourceList\n\nTest bulk resource creation with `ResourceList` and `NamespacedResourceList`:\n\n```python\nfrom ocp_resources.resource import ResourceList, NamespacedResourceList\n\n\ndef test_resource_list(fake_client):\n with ResourceList(\n client=fake_client,\n resource_class=Namespace,\n name=\"batch-ns\",\n num_resources=3,\n ) as namespaces:\n assert len(namespaces) == 3\n # All three namespaces are automatically cleaned up\n```\n\nSee [Managing Bulk Resources with ResourceList](managing-resource-lists.html) for more details.\n\n## Advanced Usage\n\n### Using the low-level API directly\n\nYou can bypass resource classes and work with the fake client's API directly \u2014 useful for testing custom logic:\n\n```python\ndef test_low_level_api(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Pod\")\n\n # Create\n pod = api.create(\n body={\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"raw-pod\", \"namespace\": \"default\"},\n \"spec\": {\"containers\": [{\"name\": \"app\", \"image\": \"nginx:latest\"}]},\n },\n namespace=\"default\",\n )\n assert pod.metadata.name == \"raw-pod\"\n assert pod.status.phase == \"Running\"\n\n # List\n pods = api.get(namespace=\"default\")\n assert len(pods.items) >= 1\n\n # Patch\n updated = api.patch(\n name=\"raw-pod\",\n namespace=\"default\",\n body={\"metadata\": {\"labels\": {\"env\": \"test\"}}},\n )\n assert updated.metadata.labels[\"env\"] == \"test\"\n\n # Delete\n api.delete(name=\"raw-pod\", namespace=\"default\")\n```\n\n### Label and field selectors\n\nThe fake client supports filtering resources by labels and fields:\n\n```python\ndef test_label_selector(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Pod\")\n\n api.create(\n body={\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"pod-a\", \"namespace\": \"default\", \"labels\": {\"app\": \"web\"}},\n \"spec\": {\"containers\": [{\"name\": \"c\", \"image\": \"nginx\"}]},\n },\n namespace=\"default\",\n )\n\n api.create(\n body={\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"pod-b\", \"namespace\": \"default\", \"labels\": {\"app\": \"api\"}},\n \"spec\": {\"containers\": [{\"name\": \"c\", \"image\": \"nginx\"}]},\n },\n namespace=\"default\",\n )\n\n # Filter by label\n web_pods = api.get(namespace=\"default\", label_selector=\"app=web\")\n assert len(web_pods.items) == 1\n assert web_pods.items[0].metadata.name == \"pod-a\"\n```\n\nField selectors support `metadata.name` and `metadata.namespace`:\n\n```python\ndef test_field_selector(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Pod\")\n result = api.get(namespace=\"default\", field_selector=\"metadata.name=my-pod\")\n```\n\n### Watch for resource changes\n\nThe `watch()` method yields existing resources as `ADDED` events:\n\n```python\ndef test_watch(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Pod\")\n\n api.create(\n body={\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"watch-pod\", \"namespace\": \"default\"},\n \"spec\": {\"containers\": [{\"name\": \"c\", \"image\": \"nginx\"}]},\n },\n namespace=\"default\",\n )\n\n for event in api.watch(namespace=\"default\"):\n assert event[\"type\"] == \"ADDED\"\n assert event[\"object\"].metadata.name\n break # Stop after first event\n```\n\n### Registering Custom Resource Definitions\n\nTo test with CRDs not bundled in the default schema, register them on the client:\n\n```python\ndef test_custom_resource(fake_client):\n # Register the CRD\n fake_client.register_resources({\n \"kind\": \"MyApp\",\n \"api_version\": \"v1alpha1\",\n \"group\": \"apps.example.com\",\n \"namespaced\": True,\n })\n\n # Use it through the low-level API\n api = fake_client.resources.get(\n api_version=\"apps.example.com/v1alpha1\",\n kind=\"MyApp\",\n )\n\n created = api.create(\n body={\n \"apiVersion\": \"apps.example.com/v1alpha1\",\n \"kind\": \"MyApp\",\n \"metadata\": {\"name\": \"my-app\", \"namespace\": \"default\"},\n \"spec\": {\"replicas\": 2},\n },\n namespace=\"default\",\n )\n assert created.metadata.name == \"my-app\"\n```\n\nYou can also register multiple CRDs at once:\n\n```python\nfake_client.register_resources([\n {\"kind\": \"MyApp\", \"api_version\": \"v1\", \"group\": \"apps.example.com\"},\n {\"kind\": \"MyConfig\", \"api_version\": \"v1beta1\", \"group\": \"config.example.com\", \"namespaced\": False},\n])\n```\n\n### Testing incremental workflows\n\nUse `@pytest.mark.incremental` to test a resource through its full lifecycle. Each test depends on the previous one succeeding:\n\n```python\nimport pytest\nfrom ocp_resources.pod import Pod\n\n\n@pytest.mark.incremental\nclass TestPodLifecycle:\n @pytest.fixture(scope=\"class\")\n def pod(self, fake_client):\n return Pod(\n client=fake_client,\n name=\"lifecycle-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n )\n\n def test_01_create(self, pod):\n deployed = pod.deploy()\n assert deployed.name == \"lifecycle-pod\"\n assert pod.exists\n\n def test_02_read(self, pod):\n assert pod.instance\n assert pod.kind == \"Pod\"\n\n def test_03_update(self, pod):\n resource_dict = pod.instance.to_dict()\n resource_dict[\"metadata\"][\"labels\"] = {\"updated\": \"true\"}\n pod.update(resource_dict=resource_dict)\n assert pod.labels[\"updated\"] == \"true\"\n\n def test_04_delete(self, pod):\n pod.clean_up(wait=False)\n assert not pod.exists\n```\n\n> **Note:** The `@pytest.mark.incremental` marker requires custom `conftest.py` hooks. Add the following to your `conftest.py`:\n> ```python\n> def pytest_runtest_makereport(item, call):\n> if call.excinfo is not None and \"incremental\" in item.keywords:\n> parent = item.parent\n> parent._previousfailed = item\n>\n> def pytest_runtest_setup(item):\n> if \"incremental\" in item.keywords:\n> previousfailed = getattr(item.parent, \"_previousfailed\", None)\n> if previousfailed is not None:\n> pytest.xfail(f\"previous test failed ({previousfailed.name})\")\n> ```\n\n### Conflict detection\n\nThe fake client raises errors for duplicate creates and missing resources, just like a real cluster:\n\n```python\nfrom fake_kubernetes_client import NotFoundError, ConflictError\n\n\ndef test_conflict_on_duplicate_create(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Namespace\")\n\n api.create(body={\"metadata\": {\"name\": \"unique-ns\"}})\n\n with pytest.raises(ConflictError):\n api.create(body={\"metadata\": {\"name\": \"unique-ns\"}})\n\n\ndef test_not_found_on_missing(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Pod\")\n\n with pytest.raises(NotFoundError):\n api.get(name=\"does-not-exist\", namespace=\"default\")\n```\n\n### Accessing response data\n\nThe fake client returns response objects that support both attribute access and dictionary access:\n\n```python\ndef test_response_access(fake_client):\n api = fake_client.resources.get(api_version=\"v1\", kind=\"Pod\")\n\n created = api.create(\n body={\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"access-pod\", \"namespace\": \"default\"},\n \"spec\": {\"containers\": [{\"name\": \"c\", \"image\": \"nginx\"}]},\n },\n namespace=\"default\",\n )\n\n # Attribute access\n assert created.metadata.name == \"access-pod\"\n assert created.status.phase == \"Running\"\n\n # Dictionary-style access\n assert created[\"metadata\"][\"name\"] == \"access-pod\"\n\n # Convert to a plain dict\n data = created.to_dict()\n assert isinstance(data, dict)\n assert data[\"metadata\"][\"name\"] == \"access-pod\"\n```\n\n## Troubleshooting\n\n**`ResourceNotFoundError` when creating a resource**\n\nThe fake client must recognize the resource's API group and kind. Standard Kubernetes and OpenShift resources are pre-registered. For CRDs, call `fake_client.register_resources(...)` before using them.\n\n**Status fields are missing or empty**\n\nPods, Deployments, Services, and Namespaces have hardcoded status templates. Other resources get a generic status with a `Ready` condition. If the resource has a schema in the bundled OpenAPI definitions, a richer status is generated automatically.\n\n**Tests interfere with each other**\n\nEach call to `get_client(fake=True)` creates an independent in-memory store. Use separate fixtures or narrower fixture scopes to isolate tests that share a client.\n\n## Related Pages\n\n- [Connecting to Clusters](connecting-to-clusters.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Querying and Watching Resources](querying-resources.html)\n- [Managing Bulk Resources with ResourceList](managing-resource-lists.html)\n- [Common Resource Patterns](common-patterns.html)\n"}, {"slug": "generating-resource-classes", "title": "Generating New Resource Classes with class-generator", "content": "# Generating New Resource Classes with class-generator\n\nScaffold Python wrapper classes for any Kubernetes or OpenShift custom resource definition (CRD) so you can manage those resources using the same patterns as built-in resources. The `class-generator` CLI introspects your cluster's OpenAPI schemas and produces ready-to-use Python files complete with typed constructors, docstrings, and `to_dict()` serialization.\n\n## Prerequisites\n\n- **openshift-python-wrapper** installed (see [Installing and Creating Your First Resource](quickstart.html))\n- `oc` or `kubectl` CLI available on your `PATH`\n- An active connection to a Kubernetes or OpenShift cluster with admin privileges (for `--kind` generation and schema updates)\n\n## Quick Example\n\nGenerate a wrapper class for the `Deployment` resource:\n\n```bash\nclass-generator -k Deployment\n```\n\nThis creates a Python file in `ocp_resources/` with a fully typed class you can import and use immediately:\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndep = Deployment(\n name=\"my-app\",\n namespace=\"default\",\n replicas=3,\n selector={\"matchLabels\": {\"app\": \"my-app\"}},\n template={...},\n)\ndep.deploy()\n```\n\n## Step-by-Step: Generating a Resource Class\n\n### 1. Generate the class file\n\nPass the CRD's `Kind` (case-sensitive) to `--kind` / `-k`:\n\n```bash\nclass-generator -k StorageCluster\n```\n\nThe output file is automatically named using snake_case \u2014 `ocp_resources/storage_cluster.py`.\n\n### 2. Preview without writing (dry run)\n\nSee exactly what would be generated without touching the filesystem:\n\n```bash\nclass-generator -k StorageCluster --dry-run\n```\n\nThe rendered Python code is printed to the console with syntax highlighting.\n\n### 3. Review the generated file\n\nEvery generated file contains:\n\n- A class inheriting from `Resource` (cluster-scoped) or `NamespacedResource` (namespace-scoped)\n- Typed `__init__` parameters extracted from the OpenAPI spec and `status` fields\n- A `to_dict()` method that serializes required and optional fields\n- A `# End of generated code` marker \u2014 any code you add **below** this line is preserved on regeneration\n\nExample generated output for `ConfigMap`:\n\n```python\nfrom typing import Any\nfrom ocp_resources.resource import NamespacedResource\n\n\nclass ConfigMap(NamespacedResource):\n \"\"\"\n ConfigMap holds configuration data for pods to consume.\n \"\"\"\n\n api_version: str = NamespacedResource.ApiVersion.V1\n\n def __init__(\n self,\n binary_data: dict[str, Any] | None = None,\n data: dict[str, Any] | None = None,\n immutable: bool | None = None,\n **kwargs: Any,\n ) -> None: ...\n```\n\n### 4. Generate multiple kinds at once\n\nPass a comma-separated list (no spaces):\n\n```bash\nclass-generator -k Pod,Service,ConfigMap\n```\n\nMultiple kinds are generated in parallel for speed.\n\n### 5. Overwrite an existing file\n\nBy default, existing files are not overwritten. Pass `--overwrite` to replace them:\n\n```bash\nclass-generator -k Deployment --overwrite\n```\n\nTo create a timestamped backup before overwriting:\n\n```bash\nclass-generator -k Deployment --overwrite --backup\n```\n\nBackups are stored in `.backups/backup-YYYYMMDD-HHMMSS/` preserving the original directory structure.\n\n### 6. Write to a custom output path\n\n```bash\nclass-generator -k MyCustomResource -o my_package/custom_resource.py\n```\n\n## Handling Duplicate Kinds\n\nSome Kubernetes kinds exist in multiple API groups. For example, `DNS` exists in both `config.openshift.io` and `operator.openshift.io`. When this happens, the generator automatically creates separate files with group-based suffixes:\n\n| Kind | API Group | Generated File |\n|------|-----------|----------------|\n| `DNS` | `config.openshift.io` | `dns_config_openshift_io.py` |\n| `DNS` | `operator.openshift.io` | `dns_operator_openshift_io.py` |\n\nBoth files define a class named `DNS` but with different `api_group` attributes. Import the one matching the API group you need.\n\n## Adding User Code to Generated Files\n\nAny code you add **below** the `# End of generated code` marker is preserved when you regenerate the file with `--overwrite`. This is how built-in resources like `ConfigMap` include custom properties:\n\n```python\n# End of generated code\n\n\n@property\ndef keys_to_hash(self):\n return [\"data\", \"binaryData\"]\n```\n\nCustom imports you add at the top of the file are also preserved during regeneration.\n\n## Updating Schemas\n\nThe generator uses a local schema cache to resolve resource definitions. If a CRD is missing (e.g., you just installed a new operator), update the cache first.\n\n### Full schema update\n\nFetch all schemas from the connected cluster:\n\n```bash\nclass-generator --update-schema\n```\n\n> **Note:** If connected to an older cluster, existing schemas are preserved. Only new or missing resources are added.\n\n### Single-resource schema update\n\nUpdate just one resource without touching the rest:\n\n```bash\nclass-generator --update-schema-for LlamaStackDistribution\n```\n\nThen generate the class:\n\n```bash\nclass-generator -k LlamaStackDistribution --overwrite\n```\n\n> **Warning:** `--update-schema` and `--update-schema-for` are mutually exclusive. Use one or the other.\n\n## Verifying Generated Files\n\nA generated file is only trustworthy while it still matches what class-generator produces.\n`--verify*` checks that without modifying anything: on drift it exits `1`, lists each file\nwith its drift classification, and prints the regenerate command.\n\n```bash\n# every generated resource\nclass-generator --verify-all\n\n# only the files this branch changed (compares against the branch's base, merge-base aware)\nclass-generator --verify-changed\n\n# what a PR touched, is what CI should check\ntox -e validate-generated\n```\n\nNarrow the report when you only care about a specific kind of drift:\n\n```bash\n# only files whose drift is blank lines and/or added parameters, nothing else\nclass-generator --verify-all --classification \"blank lines,signature code add\" --match\n\n# everything that is safe to regenerate - drops caller-breaking drift\nclass-generator --verify-all --exclude \"breaking change,schema incomplete\"\n```\n\n`breaking change` covers a removed parameter, a parameter that became required, a parameter\nemitted under a different API key, a changed base class, and a changed\n`api_group`/`api_version`. `schema incomplete` marks a class rendered without any\nparameters because a schema `$ref` could not be resolved \u2014 fix the schema, not the file.\n\nSee the [class-generator CLI reference](class-generator-cli.html#verification) for every\noption and classification.\n\n## Discovering Missing Resources\n\nFind resources on your cluster that don't have wrapper classes yet:\n\n```bash\nclass-generator --discover-missing\n```\n\n### Coverage report\n\nGet a summary of how many resources have wrapper classes:\n\n```bash\nclass-generator --coverage-report\n```\n\nOutput in JSON for CI/CD pipelines:\n\n```bash\nclass-generator --coverage-report --json\n```\n\n### Auto-generate all missing resources\n\nUpdate schemas and generate classes for every resource that's missing:\n\n```bash\nclass-generator --update-schema --generate-missing\n```\n\n> **Tip:** Use `--dry-run` with `--generate-missing` to preview what would be generated without writing any files.\n\n## Advanced Usage\n\n### Batch regeneration\n\nRegenerate all existing generated resource classes to pick up schema changes:\n\n```bash\nclass-generator --regenerate-all\n```\n\nWith backups:\n\n```bash\nclass-generator --regenerate-all --backup\n```\n\nFilter to a subset of resources using glob patterns:\n\n```bash\nclass-generator --regenerate-all --filter \"Pod*\"\nclass-generator --regenerate-all --filter \"*Service\"\n```\n\n### Adding tests\n\nGenerate test files alongside the resource class:\n\n```bash\nclass-generator -k Pod --add-tests\n```\n\nThis creates test manifests and runs them automatically with `pytest`. Tests are placed under the test manifests directory and validate that the generated class can be parsed correctly.\n\n### Shell completion\n\nAdd this to your `~/.bashrc` or `~/.zshrc` for tab completion:\n\n```bash\n# For zsh\nif type class-generator > /dev/null; then eval \"$(_CLASS_GENERATOR_COMPLETE=zsh_source class-generator)\"; fi\n\n# For bash\nif type class-generator > /dev/null; then eval \"$(_CLASS_GENERATOR_COMPLETE=bash_source class-generator)\"; fi\n```\n\n### Verbose output\n\nEnable debug logging to see exactly what the generator is doing:\n\n```bash\nclass-generator -k Deployment -v\n```\n\n### When a kind is missing from the schema\n\nIf the kind you request is not in the local schema cache, the CLI prompts you:\n\n```\ndeployment not found in schema mapping, Do you want to run --update-schema and retry? [Y/N]:\n```\n\nAnswering `y` triggers a schema update and retries generation automatically.\n\n> **Note:** The prompt only appears in interactive mode. When generating multiple kinds or running in batch mode, missing kinds are skipped with a warning. Run `--update-schema` separately beforehand in CI environments.\n\n### API group and version warnings\n\nIf a generated resource uses an API group or version that hasn't been registered in the base `Resource` class, the generator logs a warning like:\n\n```\nMissing API Group in Resource\nPlease add `Resource.ApiGroup.MY_GROUP_IO = my-group.io` manually into\nocp_resources/resource.py under Resource class > ApiGroup class.\n```\n\nFollow the instructions to register the API group so the resource can connect to the correct API endpoint. See [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) for details on how `ApiGroup` and `ApiVersion` are used.\n\n## CLI Options Reference\n\n| Option | Description |\n|--------|-------------|\n| `-k`, `--kind` | Kind(s) to generate (comma-separated) |\n| `-o`, `--output-file` | Custom output file path |\n| `--overwrite` | Overwrite existing files |\n| `--dry-run` | Preview output without writing files |\n| `--add-tests` | Generate test files for the kind |\n| `--update-schema` | Update all schema files from cluster |\n| `--update-schema-for` | Update schema for a single kind |\n| `--discover-missing` | Find resources without wrapper classes |\n| `--coverage-report` | Show coverage statistics |\n| `--json` | Output reports in JSON format |\n| `--generate-missing` | Generate classes for all missing resources |\n| `--regenerate-all` | Regenerate all existing generated classes |\n| `--backup` | Create backup before overwriting/regenerating |\n| `--filter` | Glob pattern to filter `--regenerate-all` |\n| `-v`, `--verbose` | Enable debug logging |\n\nFor the full CLI reference, see [class-generator CLI Reference](class-generator-cli.html).\n\n## Troubleshooting\n\n**\"Neither 'oc' nor 'kubectl' binary found in PATH\"**\nInstall `oc` from the [OpenShift mirror](https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/stable/) or `kubectl` from the [Kubernetes docs](https://kubernetes.io/docs/tasks/tools/). Ensure the binary is on your `PATH`.\n\n**\"Resource kind 'X' not found\"**\nThe kind is not in the local schema cache. Run `class-generator --update-schema` while connected to a cluster that has the CRD installed, then retry.\n\n**Generated filename looks wrong (e.g., `c_d_i_config.py`)**\nSingle-letter segments in snake_case names indicate the camelCase-to-snake_case converter doesn't recognize an acronym. The generator raises an error with instructions to add the acronym to the converter.\n\n**\"Failed to fetch OpenAPI v3 index\"**\nEnsure you are logged in to the cluster with sufficient privileges. The generator needs to access the `/openapi/v3` endpoint, which typically requires cluster-admin access.\n\n## Related Pages\n\n- [class-generator CLI Reference](class-generator-cli.html)\n- [Schema Validation and Code Generation Architecture](schema-validation-internals.html)\n- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html)\n- [Resource and NamespacedResource API](resource-api.html)\n- [Validating Resources Against OpenAPI Schemas](validating-resources.html)\n"}, {"slug": "class-generator-cli", "title": "class-generator CLI Reference", "content": "# class-generator CLI Reference\n\nThe `class-generator` command generates Python wrapper classes for Kubernetes and OpenShift resources. It is installed as a console script entry point from the `openshift-python-wrapper` package.\n\n```\nclass-generator = \"class_generator.cli:main\"\n```\n\n```bash\npip install openshift-python-wrapper\n# or\nuv tool install openshift-python-wrapper\n```\n\n> **Tip:** Enable shell completion by adding to `~/.bashrc` or `~/.zshrc`:\n> ```bash\n> if type class-generator > /dev/null; then eval \"$(_CLASS_GENERATOR_COMPLETE=zsh_source class-generator)\"; fi\n> ```\n\n## Synopsis\n\n```\nclass-generator [OPTIONS]\n```\n\nAt least one action option must be specified: `--kind`, `--update-schema`, `--update-schema-for`, `--discover-missing`, `--coverage-report`, `--generate-missing`, or `--regenerate-all`.\n\n---\n\n## Options Reference\n\n### Kind Generation\n\n#### `-k`, `--kind`\n\n| Property | Value |\n|----------|-------|\n| **Type** | `STRING` |\n| **Default** | `None` |\n| **Required** | No (but at least one action must be specified) |\n| **Requires** | Connected cluster with admin privileges |\n\nGenerate Python wrapper classes for the specified Kubernetes resource Kind(s). Multiple kinds can be comma-separated (no spaces).\n\nWhen a kind is not found in the local schema mapping file, the CLI interactively prompts to run `--update-schema` (only in interactive CLI mode).\n\n```bash\n# Single kind\nclass-generator -k Pod\n\n# Multiple kinds (processed in parallel)\nclass-generator -k Deployment,Pod,ConfigMap\n```\n\nWhen multiple kinds share the same Kind name but belong to different API groups (e.g., `DNS` from `config.openshift.io` and `operator.openshift.io`), separate files are generated with API group suffixes:\n\n```\ndns_config_openshift_io.py\ndns_operator_openshift_io.py\n```\n\n---\n\n#### `-o`, `--output-file`\n\n| Property | Value |\n|----------|-------|\n| **Type** | `PATH` |\n| **Default** | `ocp_resources/.py` |\n| **Required** | No |\n\nOverride the output file path for the generated Python module. If not provided, the filename is derived from the Kind using `convert_camel_case_to_snake_case`.\n\n```bash\nclass-generator -k Pod -o my_custom_pod.py\n```\n\n> **Note:** When generating multiple comma-separated kinds, the `--output-file` value applies to all kinds. For independent output paths, run separate commands.\n\n---\n\n#### `--overwrite`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nOverwrite an existing output file. Without this flag, if the target file already exists, a `_TEMP.py` suffixed file is created instead.\n\nWhen overwriting, any user-added code blocks (code after the `# End of generated code` marker) and user imports are preserved in the regenerated file.\n\n```bash\nclass-generator -k Pod --overwrite\n```\n\n---\n\n#### `--dry-run`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nPreview the generated output without writing any files. The generated Python code is printed to the console with syntax highlighting and line numbers using Rich.\n\n```bash\nclass-generator -k Pod --dry-run\n```\n\n---\n\n#### `--backup`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n| **Requires** | `--regenerate-all` or `--overwrite` |\n\nCreate a timestamped backup of existing files before overwriting or regenerating. Backups are stored in `.backups/backup-YYYYMMDD-HHMMSS/` preserving the original directory structure.\n\n```bash\nclass-generator -k Pod --overwrite --backup\n```\n\n> **Note:** Using `--backup` without either `--regenerate-all` or `--overwrite` results in a constraint error.\n\n---\n\n### Test Generation\n\n#### `--add-tests`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n| **Requires** | `-k`/`--kind` |\n\nGenerate test files for the specified Kind and run them. This performs two actions:\n\n1. Generates a test manifest in `class_generator/tests/manifests//` and regenerates `class_generator/tests/test_class_generator.py` from the Jinja2 template.\n2. Runs the generated test file using `uv run --group tests pytest class_generator/tests/test_class_generator.py`.\n\n```bash\nclass-generator -k Pod --add-tests\n```\n\n> **Warning:** `--add-tests` cannot be used without `-k`/`--kind`. Running `class-generator --add-tests` alone exits with a non-zero status.\n\n---\n\n### Schema Management\n\n#### `--update-schema`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n| **Requires** | Connected cluster; `oc` or `kubectl` in PATH |\n| **Mutually exclusive with** | `--update-schema-for` |\n\nFetch all resource schemas from the connected cluster's OpenAPI v3 endpoints and update the local schema files:\n\n- `class_generator/schema/__resources-mappings.json` (compressed as `.json.gz`)\n- `class_generator/schema/_definitions.json`\n\nThe update strategy depends on the cluster version:\n\n| Cluster Version | Behavior |\n|-----------------|----------|\n| Same or newer than last update | Full update \u2014 fetches all schemas, updates existing resources |\n| Older than last update | Incremental \u2014 only adds missing resources, preserves existing schemas |\n\nWhen used alone, exits after updating. When combined with `--generate-missing`, continues to resource generation after the update.\n\n```bash\n# Update schema only\nclass-generator --update-schema\n\n# Update schema then generate missing resources\nclass-generator --update-schema --generate-missing\n```\n\n> **Note:** When `--update-schema` is used without `--generate-missing`, it cannot be combined with `-k`, `--discover-missing`, `--coverage-report`, `--dry-run`, `--overwrite`, `-o`, `--add-tests`, or `--regenerate-all`.\n\n---\n\n#### `--update-schema-for`\n\n| Property | Value |\n|----------|-------|\n| **Type** | `STRING` |\n| **Default** | `None` |\n| **Requires** | Connected cluster; `oc` or `kubectl` in PATH |\n| **Mutually exclusive with** | `--update-schema` |\n\nUpdate the schema for a single resource Kind without affecting other resources. The Kind name is **case-sensitive**.\n\nThis fetches only the API paths relevant to the specified Kind, updates (or adds) its schema in the mapping file, and exits.\n\n```bash\nclass-generator --update-schema-for LlamaStackDistribution\n```\n\nUse cases:\n- Connected to an older cluster but need to update a specific CRD\n- A new operator was installed and you need its resource schema\n- Refreshing just one resource without a full schema update\n\nAfter updating, regenerate the class:\n\n```bash\nclass-generator --update-schema-for LlamaStackDistribution\nclass-generator -k LlamaStackDistribution --overwrite\n```\n\n**Errors:**\n\n| Error | Cause |\n|-------|-------|\n| `ResourceNotFoundError` | The Kind is not found on the cluster (CRD not installed or name misspelled) |\n| `RuntimeError` | API paths not found or schema extraction failed |\n\n> **Note:** Cannot be combined with `-k`, `--discover-missing`, `--coverage-report`, `--generate-missing`, or `--regenerate-all`.\n\n---\n\n### Coverage and Discovery\n\n#### `--discover-missing`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nAnalyze resource coverage by comparing schema-mapped resources against implemented wrapper classes in `ocp_resources/`. Generates a console report showing coverage statistics and missing resources.\n\n```bash\nclass-generator --discover-missing\n```\n\n---\n\n#### `--coverage-report`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nGenerate a detailed coverage report showing:\n\n| Metric | Description |\n|--------|-------------|\n| Total Resources in Schema | Number of resource Kinds in the schema mapping |\n| Auto-Generated Resources | Wrapper classes with the generated marker |\n| Coverage | Percentage of mapped resources with generated classes |\n| Missing (Not Generated) | Resources in schema but without generated classes |\n| Manual Implementations | Resource classes without the generated marker |\n\n```bash\n# Console output (default)\nclass-generator --coverage-report\n\n# JSON output\nclass-generator --coverage-report --json\n```\n\n---\n\n#### `--json`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nOutput reports in JSON format instead of Rich console tables. Applies to `--coverage-report`, `--discover-missing`, and `--generate-missing`.\n\nThe JSON output structure:\n\n```json\n{\n \"generated_resources\": [\"ConfigMap\", \"Deployment\", \"Pod\"],\n \"manual_resources\": [\"VirtualMachine\"],\n \"missing_resources\": [{\"kind\": \"Binding\"}, {\"kind\": \"ComponentStatus\"}],\n \"coverage_stats\": {\n \"total_in_mapping\": 400,\n \"total_generated\": 197,\n \"total_manual\": 25,\n \"coverage_percentage\": 49.25,\n \"missing_count\": 203\n }\n}\n```\n\n```bash\nclass-generator --coverage-report --json\n```\n\n---\n\n#### `--generate-missing`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nGenerate wrapper classes for all resources found in the schema mapping that do not yet have generated files. Each missing resource Kind is passed to `class_generator()` individually.\n\nCan be combined with `--update-schema` to first refresh the schema, then generate all missing classes.\n\n```bash\n# Generate missing resources\nclass-generator --generate-missing\n\n# Update schema first, then generate missing\nclass-generator --update-schema --generate-missing\n\n# Dry run to preview\nclass-generator --generate-missing --dry-run\n```\n\n---\n\n### Batch Regeneration\n\n#### `--regenerate-all`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nRegenerate all existing generated resource classes using the latest schemas. Only files containing the `# Generated using` marker in `ocp_resources/` are processed.\n\nRegeneration runs in parallel (up to 10 workers). User-added code (below `# End of generated code`) is preserved during regeneration.\n\n```bash\n# Regenerate all resources\nclass-generator --regenerate-all\n\n# With backup\nclass-generator --regenerate-all --backup\n\n# Dry run\nclass-generator --regenerate-all --dry-run\n\n# Filter to specific resources\nclass-generator --regenerate-all --filter \"Pod*\"\n```\n\nOutput summary:\n\n```\nRegeneration complete: 195 succeeded, 2 failed\nBackup files stored in: .backups/backup-20260705-143022\n```\n\n---\n\n#### `--filter`\n\n| Property | Value |\n|----------|-------|\n| **Type** | `STRING` |\n| **Default** | `None` |\n| **Requires** | `--regenerate-all` |\n\nFilter which resources to regenerate using a glob pattern matched against the resource Kind name. Uses `fnmatch` semantics.\n\n```bash\n# Regenerate only Pod-related resources\nclass-generator --regenerate-all --filter \"Pod*\"\n\n# Regenerate resources ending in \"Service\"\nclass-generator --regenerate-all --filter \"*Service\"\n\n# Regenerate a specific resource\nclass-generator --regenerate-all --filter \"VirtualMachine\"\n```\n\n---\n\n### Verification\n\nVerify that generated files still match what class-generator would produce. No file is modified.\n\n| Option | Description |\n|--------|-------------|\n| `--verify` | Verify the kinds passed via `--kind` |\n| `--verify-all` | Verify every generated resource; combine with `--filter` to narrow the scope |\n| `--verify-changed` | Verify only resources changed relative to `--base`; untouched files are not checked |\n| `--base TEXT` | Ref compared against by `--verify-changed`. Defaults to the current branch's upstream, then to the clone's remote HEAD; the ref is fetched when the clone lacks it and the diff uses the merge base |\n| `--classification TEXT` | Comma-separated drift classifications; only files matching them are reported and fail the run |\n| `--match` | Require the drift to be *within* `--classification` (a subset) instead of merely containing one of them |\n| `--exclude TEXT` | Drop files whose drift contains any of these classifications, leaving only drift that is safe to regenerate |\n| `--jobs INTEGER` | Resources verified in parallel (default 10) |\n\n`--classification`, `--match` and `--exclude` require a verify mode; unknown\nclassifications are rejected with the list of valid values.\n\nEvery reported file is generator output, so the printed repair command is the plain\nper-kind form:\n\n```bash\nclass-generator --kind Deployment --overwrite\n```\n\nOnly files whose name class-generator writes are verified (`.py`, or\n`_.py` for a kind in several groups). A copied file under another name\nis not generator output and is ignored.\n\n#### Drift classifications\n\n| Classification | Meaning |\n|----------------|---------|\n| `blank lines` | Only blank-line differences |\n| `docstring add` / `docstring delete` | Docstring text differs |\n| `code add` / `code delete` | Code lines differ |\n| `signature code add` | Parameters were added (all generated parameters carry defaults, so this is not breaking) |\n| `signature code remove` | Parameters were removed |\n| `breaking change` | A caller-visible break: a parameter was removed, a parameter became required, a parameter is emitted under a different API key, the base class changed, or `api_group`/`api_version` changed |\n| `schema incomplete` | The rendered class has no parameters while the file has some \u2014 an unresolvable `$ref`. Reported in addition to `breaking change` |\n| `file does not exist` | The expected generated file is missing (or was deleted on this branch) |\n| `formatting failed` | The rendered output could not be formatted |\n| `not in schema` | The kind is absent from the resources mapping |\n\n`code delete` on its own is not breaking: it also covers guard lines removed when a\nfield becomes optional, rewritten statements and reflowed code.\n\n#### Exit status\n\n`0` when everything matches, `1` when any file drifted or could not be verified.\n\n#### CI\n\n`tox -e validate-generated` runs `--verify-changed` with no arguments, so tox stays\nCI-independent:\n\n```bash\ntox -e validate-generated\n```\n\n### Logging\n\n#### `-v`, `--verbose`\n\n| Property | Value |\n|----------|-------|\n| **Type** | Flag |\n| **Default** | `False` |\n\nEnable verbose output with debug-level logs. Sets `DEBUG` level on the following loggers:\n\n- `class_generator.core.schema`\n- `class_generator.core.generator`\n- `class_generator.core.coverage`\n- `class_generator.core.discovery`\n- `class_generator.cli`\n- `class_generator.utils`\n- `ocp_resources`\n\n```bash\nclass-generator -k Pod -v\n```\n\n---\n\n## Constraint Rules\n\nThe CLI enforces the following option constraints:\n\n| Constraint | Rule |\n|------------|------|\n| `--update-schema` \u2194 `--update-schema-for` | Mutually exclusive |\n| `--update-schema` (alone) | Cannot combine with `-k`, `--discover-missing`, `--coverage-report`, `--dry-run`, `--overwrite`, `-o`, `--add-tests`, `--regenerate-all` |\n| `--update-schema-for` | Cannot combine with `-k`, `--discover-missing`, `--coverage-report`, `--generate-missing`, `--regenerate-all` |\n| `--backup` | Requires `--regenerate-all` or `--overwrite` |\n| `--filter` | Requires `--regenerate-all`, `--verify-all` or `--verify-changed` |\n| `--verify` / `--verify-all` / `--verify-changed` | Mutually exclusive, and cannot combine with `--regenerate-all`, `--backup`, `--overwrite`, `--add-tests` or `--dry-run` |\n| `--verify` | Requires `--kind` |\n| `--classification` / `--exclude` | Require a verify mode |\n| `--match` | Requires `--classification` |\n| No options | Exits with error \u2014 at least one action required |\n\n---\n\n## Execution Order\n\nWhen multiple compatible options are specified together, the CLI processes them in this fixed order:\n\n1. `--update-schema-for` (exits after completion)\n2. `--update-schema` (exits unless `--generate-missing` is also set)\n3. `--coverage-report` / `--discover-missing` / `--generate-missing` (coverage analysis and reporting)\n4. `--generate-missing` (generates classes for missing resources)\n5. `--verify` / `--verify-all` / `--verify-changed` (verification, exits after completion)\n6. `--regenerate-all` (batch regeneration, exits after completion)\n7. `-k`/`--kind` (normal kind generation)\n8. `--add-tests` (test generation and execution)\n\n---\n\n## Exit Codes\n\n| Code | Meaning |\n|------|---------|\n| `0` | Success |\n| `1` | Generation failure, schema update failure, resource not found, or any kind in a batch failed |\n| `2` | Invalid CLI arguments or constraint violation |\n\n---\n\n## Programmatic API\n\nThe generation logic can be invoked directly from Python. See [Generating New Resource Classes with class-generator](generating-resource-classes.html) for usage examples.\n\n### `class_generator.core.generator.class_generator()`\n\n```python\nfrom class_generator.core.generator import class_generator\n\ngenerated_files: list[str] = class_generator(\n kind=\"Pod\",\n overwrite=False,\n dry_run=False,\n output_file=\"\",\n output_dir=\"\",\n add_tests=False,\n called_from_cli=True,\n update_schema_executed=False,\n)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `kind` | `str` | *(required)* | Kubernetes resource Kind |\n| `overwrite` | `bool` | `False` | Overwrite existing files |\n| `dry_run` | `bool` | `False` | Preview output without writing files |\n| `output_file` | `str` | `\"\"` | Specific output file path |\n| `output_dir` | `str` | `\"\"` | Output directory (defaults to `ocp_resources`) |\n| `add_tests` | `bool` | `False` | Generate test files |\n| `called_from_cli` | `bool` | `True` | Enables interactive prompts when `True` |\n| `update_schema_executed` | `bool` | `False` | Whether schema update was already performed |\n\n**Returns:** `list[str]` \u2014 List of generated file paths. Empty list if the kind is not found in the schema mapping (when `called_from_cli=False`).\n\n**Raises:**\n- `RuntimeError` \u2014 Kind not found after schema update, or user declined schema update\n- `ValueError` \u2014 Generated filename contains invalid patterns (single-letter segments)\n\n---\n\n### `class_generator.core.generator.generate_resource_file_from_dict()`\n\n```python\nfrom class_generator.core.generator import generate_resource_file_from_dict\n\norig_filename, generated_filename = generate_resource_file_from_dict(\n resource_dict={\"kind\": \"Pod\", ...},\n overwrite=False,\n dry_run=False,\n output_file=\"\",\n add_tests=False,\n output_file_suffix=\"\",\n output_dir=\"\",\n)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `resource_dict` | `dict[str, Any]` | *(required)* | Dictionary containing parsed resource information |\n| `overwrite` | `bool` | `False` | Overwrite existing files |\n| `dry_run` | `bool` | `False` | Preview without writing |\n| `output_file` | `str` | `\"\"` | Specific output file path |\n| `add_tests` | `bool` | `False` | Generate test files under `class_generator/tests/manifests/` |\n| `output_file_suffix` | `str` | `\"\"` | Suffix appended to filename (for API group disambiguation) |\n| `output_dir` | `str` | `\"\"` | Output directory (defaults to `ocp_resources`) |\n\n**Returns:** `tuple[str, str]` \u2014 `(original_filename, generated_filename)`. These differ when a `_TEMP.py` file is created.\n\n---\n\n### `class_generator.core.schema.update_kind_schema()`\n\n```python\nfrom class_generator.core.schema import update_kind_schema\n\nupdate_kind_schema(client=None)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `client` | `str \\| None` | `None` | Path to `oc`/`kubectl` binary. Auto-detected if `None`. |\n\n**Raises:**\n- `ClusterVersionError` \u2014 Cannot determine cluster version\n- `RuntimeError` \u2014 Failed to fetch OpenAPI v3 index\n- `OSError` \u2014 Failed to write schema files\n\n---\n\n### `class_generator.core.schema.update_single_resource_schema()`\n\n```python\nfrom class_generator.core.schema import update_single_resource_schema\n\nupdate_single_resource_schema(kind=\"LlamaStackDistribution\", client=None)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `kind` | `str` | *(required)* | Resource Kind name (case-sensitive) |\n| `client` | `str \\| None` | `None` | Path to `oc`/`kubectl` binary. Auto-detected if `None`. |\n\n**Raises:**\n- `ResourceNotFoundError` \u2014 Kind not found on the cluster\n- `RuntimeError` \u2014 Schema extraction or save failed\n\n---\n\n### `class_generator.core.coverage.analyze_coverage()`\n\n```python\nfrom class_generator.core.coverage import analyze_coverage\n\nresult: dict[str, Any] = analyze_coverage(resources_dir=\"ocp_resources\")\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `resources_dir` | `str` | `\"ocp_resources\"` | Directory to scan for wrapper classes |\n\n**Returns:** `dict[str, Any]` with keys:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| `generated_resources` | `list[str]` | Sorted list of auto-generated resource class names |\n| `manual_resources` | `list[str]` | Sorted list of manually written resource class names |\n| `missing_resources` | `list[dict]` | List of `{\"kind\": \"...\"}` for resources in schema but not generated |\n| `coverage_stats` | `dict` | Statistics including `total_in_mapping`, `total_generated`, `total_manual`, `coverage_percentage`, `missing_count` |\n\n---\n\n### `class_generator.core.discovery.discover_generated_resources()`\n\n```python\nfrom class_generator.core.discovery import discover_generated_resources\n\nresources: list[dict[str, Any]] = discover_generated_resources()\n```\n\n**Returns:** `list[dict[str, Any]]` \u2014 Each dict contains:\n\n| Key | Type | Description |\n|-----|------|-------------|\n| `path` | `str` | Full path to the resource file |\n| `kind` | `str` | Resource class name |\n| `filename` | `str` | File name without extension |\n| `has_user_code` | `bool` | Whether file contains user modifications below `# End of generated code` |\n\n---\n\n### `class_generator.core.discovery.discover_cluster_resources()`\n\n```python\nfrom class_generator.core.discovery import discover_cluster_resources\n\nresources: dict[str, list[dict[str, Any]]] = discover_cluster_resources(\n client=None,\n api_group_filter=None,\n)\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `client` | `DynamicClient \\| None` | `None` | Kubernetes dynamic client. Creates one if `None`. |\n| `api_group_filter` | `str \\| None` | `None` | Filter by API group name |\n\n**Returns:** `dict[str, list[dict[str, Any]]]` \u2014 Mapping of API version to list of resource dicts (`name`, `kind`, `namespaced`).\n\n**Raises:** `ValueError` \u2014 If client is not a `DynamicClient` instance.\n\n---\n\n## Exceptions\n\n### `class_generator.exceptions.ResourceNotFoundError`\n\n```python\nfrom class_generator.exceptions import ResourceNotFoundError\n```\n\nRaised when a resource Kind is not found in the schema mapping or on the cluster.\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `kind` | `str` | The Kind that was not found |\n\n### `class_generator.core.schema.ClusterVersionError`\n\n```python\nfrom class_generator.core.schema import ClusterVersionError\n```\n\nRaised when the cluster version cannot be determined (client binary missing, cluster unreachable, or authentication failure).\n\n---\n\n## Schema Files\n\nThe CLI manages two schema files under `class_generator/schema/`:\n\n| File | Purpose |\n|------|---------|\n| `__resources-mappings.json.gz` | Compressed JSON mapping of lowercase Kind \u2192 list of schemas with GVK metadata and namespaced status |\n| `_definitions.json` | JSON Schema definitions for `$ref` resolution during validation |\n| `__cluster_version__.txt` | Last cluster version used for schema generation |\n\nSee [Schema Validation and Code Generation Architecture](schema-validation-internals.html) for details on how these files are structured and consumed.\n\n---\n\n## Common Workflows\n\n### Generate a new resource class\n\n```bash\nclass-generator -k MyCustomResource\n```\n\n### Update schema and regenerate all classes\n\n```bash\nclass-generator --update-schema\nclass-generator --regenerate-all --backup\n```\n\n### Add a new CRD to an older cluster\n\n```bash\nclass-generator --update-schema-for MyNewCRD\nclass-generator -k MyNewCRD\n```\n\n### CI/CD coverage check\n\n```bash\nclass-generator --coverage-report --json > coverage.json\n```\n\n### Preview changes before writing\n\n```bash\nclass-generator -k Pod --dry-run\nclass-generator --regenerate-all --dry-run\n```\n\n---\n\n## Related Pages\n\n- [Generating New Resource Classes with class-generator](generating-resource-classes.html) \u2014 Step-by-step guide for scaffolding resource classes\n- [Resource and NamespacedResource API](resource-api.html) \u2014 API reference for the base classes that generated code extends\n- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) \u2014 How generated subclasses fit into the class hierarchy\n- [Schema Validation and Code Generation Architecture](schema-validation-internals.html) \u2014 Internals of schema fetching, caching, and code generation\n- [Environment Variables and Configuration](environment-variables.html) \u2014 Environment variables that affect runtime behavior\n\n## Related Pages\n\n- [Generating New Resource Classes with class-generator](generating-resource-classes.html)\n- [Schema Validation and Code Generation Architecture](schema-validation-internals.html)\n- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html)\n- [Resource and NamespacedResource API](resource-api.html)\n- [Environment Variables and Configuration](environment-variables.html)\n"}, {"slug": "common-patterns", "title": "Common Resource Patterns", "content": "# Common Resource Patterns\n\nCopy-paste recipes for the most common Kubernetes and OpenShift resource operations using openshift-python-wrapper.\n\n> **Note:** All recipes assume you already have a connected client. See [Connecting to Clusters](connecting-to-clusters.html) for setup instructions.\n\n```python\n# Preamble used by all recipes below\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n```\n\n---\n\n## Create and Run a Pod\n\nCreate a pod with a single container and wait for it to start.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n client=client,\n name=\"my-nginx\",\n namespace=\"default\",\n containers=[\n {\n \"name\": \"nginx\",\n \"image\": \"nginx:1.25\",\n \"ports\": [{\"containerPort\": 80}],\n }\n ],\n restart_policy=\"Never\",\n)\npod.deploy()\npod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)\n```\n\nThe `deploy()` method creates the pod on the cluster. `wait_for_status` polls until the pod reaches `Running`. Use `restart_policy=\"Always\"` for long-running pods.\n\n---\n\n## Create a Pod as a Context Manager\n\nAutomatically clean up a pod when the block exits \u2014 ideal for tests.\n\n```python\nfrom ocp_resources.pod import Pod\n\nwith Pod(\n client=client,\n name=\"test-curl\",\n namespace=\"default\",\n containers=[\n {\n \"name\": \"curl\",\n \"image\": \"curlimages/curl:8.5.0\",\n \"command\": [\"sleep\", \"3600\"],\n }\n ],\n) as pod:\n pod.wait_for_status(status=Pod.Status.RUNNING, timeout=60)\n output = pod.execute(command=[\"curl\", \"-s\", \"http://httpbin.org/get\"])\n print(output)\n# Pod is automatically deleted here\n```\n\nThe context manager calls `deploy()` on enter and `clean_up()` on exit. Set `teardown=False` to skip automatic deletion.\n\n> **Tip:** See [Creating and Managing Resources](creating-and-managing-resources.html) for all lifecycle management patterns.\n\n---\n\n## Execute a Command Inside a Pod\n\nRun a shell command inside an already-running pod and capture the output.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-nginx\", namespace=\"default\", ensure_exists=True)\noutput = pod.execute(command=[\"cat\", \"/etc/nginx/nginx.conf\"], timeout=30)\nprint(output)\n```\n\n`execute()` streams output via the Kubernetes exec API. Use `container=\"sidecar\"` to target a specific container in multi-container pods. Set `ignore_rc=True` to suppress errors from non-zero exit codes.\n\n---\n\n## Get Pod Logs\n\nRetrieve logs from a running or completed pod.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-nginx\", namespace=\"default\", ensure_exists=True)\n\n# Full logs\nprint(pod.log())\n\n# Last 50 lines\nprint(pod.log(tail_lines=50))\n\n# Logs from a specific container\nprint(pod.log(container=\"sidecar\"))\n```\n\nThe `log()` method passes kwargs through to the Kubernetes `read_namespaced_pod_log` API, so all standard parameters like `tail_lines`, `since_seconds`, and `container` are supported.\n\n---\n\n## Create a Deployment and Wait for Replicas\n\nDeploy an application with multiple replicas and wait until they are all ready.\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndep = Deployment(\n client=client,\n name=\"web-app\",\n namespace=\"default\",\n replicas=3,\n selector={\"matchLabels\": {\"app\": \"web-app\"}},\n template={\n \"metadata\": {\"labels\": {\"app\": \"web-app\"}},\n \"spec\": {\n \"containers\": [\n {\n \"name\": \"app\",\n \"image\": \"nginx:1.25\",\n \"ports\": [{\"containerPort\": 80}],\n }\n ]\n },\n },\n)\ndep.deploy()\ndep.wait_for_replicas(deployed=True, timeout=300)\n```\n\n`wait_for_replicas` polls until `availableReplicas == readyReplicas == updatedReplicas == spec.replicas`. Pass `deployed=False` to wait for all replicas to be scaled down.\n\n---\n\n## Scale a Deployment\n\nChange the replica count of an existing deployment.\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndep = Deployment(client=client, name=\"web-app\", namespace=\"default\", ensure_exists=True)\n\n# Scale up\ndep.scale_replicas(replica_count=5)\ndep.wait_for_replicas(deployed=True, timeout=300)\n\n# Scale down\ndep.scale_replicas(replica_count=1)\ndep.wait_for_replicas(deployed=True, timeout=300)\n```\n\n`scale_replicas` patches the deployment's `spec.replicas` field. Always follow with `wait_for_replicas` to confirm the rollout completes.\n\n---\n\n## Create a Service\n\nExpose a deployment via a ClusterIP service.\n\n```python\nfrom ocp_resources.service import Service\n\nsvc = Service(\n client=client,\n name=\"web-app-svc\",\n namespace=\"default\",\n selector={\"app\": \"web-app\"},\n ports=[\n {\n \"protocol\": \"TCP\",\n \"port\": 80,\n \"targetPort\": 80,\n }\n ],\n type=\"ClusterIP\",\n)\nsvc.deploy()\n```\n\nChange `type` to `\"NodePort\"` or `\"LoadBalancer\"` as needed. The `selector` must match labels on your target pods.\n\n---\n\n## Create an OpenShift Route\n\nExpose a service externally via an OpenShift Route.\n\n```python\nfrom ocp_resources.route import Route\n\nroute = Route(\n client=client,\n name=\"web-app-route\",\n namespace=\"default\",\n service=\"web-app-svc\",\n)\nroute.deploy()\n\n# Get the assigned hostname\nprint(route.host)\n```\n\nFor TLS re-encrypt routes, pass `destination_ca_cert=\"\"`. Access the exposed service name with `route.exposed_service` and the TLS termination type with `route.termination`.\n\n---\n\n## Create a Namespace\n\nCreate a namespace (or use as a context manager for automatic cleanup).\n\n```python\nfrom ocp_resources.namespace import Namespace\n\n# Simple creation\nns = Namespace(client=client, name=\"test-ns\")\nns.deploy()\n\n# As a context manager (deleted on exit)\nwith Namespace(client=client, name=\"ephemeral-ns\") as ns:\n # ... do work in the namespace ...\n pass\n```\n\n`Namespace` extends `Resource` (cluster-scoped), so no `namespace` parameter is needed.\n\n---\n\n## Create a ConfigMap\n\nStore configuration data for pods to consume.\n\n```python\nfrom ocp_resources.config_map import ConfigMap\n\ncm = ConfigMap(\n client=client,\n name=\"app-config\",\n namespace=\"default\",\n data={\n \"DATABASE_URL\": \"postgres://db:5432/myapp\",\n \"LOG_LEVEL\": \"info\",\n },\n)\ncm.deploy()\n```\n\nUse `binary_data` for non-UTF-8 content. Set `immutable=True` to prevent changes after creation.\n\n---\n\n## Create a Secret\n\nStore sensitive data such as credentials.\n\n```python\nfrom ocp_resources.secret import Secret\n\nsecret = Secret(\n client=client,\n name=\"db-credentials\",\n namespace=\"default\",\n string_data={\n \"username\": \"admin\",\n \"password\": \"s3cur3-pa$$word\",\n },\n type=\"Opaque\",\n)\nsecret.deploy()\n```\n\nUse `data_dict` instead of `string_data` if your values are already base64-encoded. Secret data is automatically hashed in log output for security.\n\n---\n\n## Create a Job\n\nRun a one-off task to completion.\n\n```python\nfrom ocp_resources.job import Job\n\njob = Job(\n client=client,\n name=\"db-migration\",\n namespace=\"default\",\n backoff_limit=3,\n restart_policy=\"Never\",\n containers=[\n {\n \"name\": \"migrate\",\n \"image\": \"my-app:latest\",\n \"command\": [\"python\", \"manage.py\", \"migrate\"],\n }\n ],\n)\njob.deploy()\njob.wait_for_condition(\n condition=Job.Condition.COMPLETE,\n status=Job.Condition.Status.TRUE,\n timeout=300,\n)\n```\n\nSet `background_propagation_policy=\"Background\"` to delete leftover pods when the job is cleaned up. The `backoff_limit` controls how many times the job retries on failure.\n\n---\n\n## Create a PersistentVolumeClaim\n\nRequest persistent storage for your workloads.\n\n```python\nfrom ocp_resources.persistent_volume_claim import PersistentVolumeClaim\n\npvc = PersistentVolumeClaim(\n client=client,\n name=\"app-data\",\n namespace=\"default\",\n accessmodes=PersistentVolumeClaim.AccessMode.RWO,\n size=\"10Gi\",\n storage_class=\"gp3-csi\",\n volume_mode=PersistentVolumeClaim.VolumeMode.FILE,\n)\npvc.deploy()\npvc.wait_for_status(status=PersistentVolumeClaim.Status.BOUND, timeout=120)\n```\n\nUse the `AccessMode` and `VolumeMode` constants instead of raw strings. The `storage_class` must match an available StorageClass on your cluster.\n\n---\n\n## Create a DataVolume (KubeVirt / CDI)\n\nImport a VM disk image into a PVC using the Containerized Data Importer.\n\n```python\nfrom ocp_resources.datavolume import DataVolume\n\ndv = DataVolume(\n client=client,\n name=\"fedora-disk\",\n namespace=\"default\",\n source_dict={\n \"http\": {\n \"url\": \"https://download.fedoraproject.org/pub/fedora/linux/releases/40/Cloud/x86_64/images/Fedora-Cloud-Base-40-1.14.x86_64.qcow2\"\n }\n },\n size=\"30Gi\",\n storage_class=\"ocs-storagecluster-ceph-rbd-virtualization\",\n access_modes=DataVolume.AccessMode.RWX,\n volume_mode=DataVolume.VolumeMode.BLOCK,\n api_name=\"storage\",\n)\ndv.deploy()\ndv.wait_for_dv_success(timeout=600)\n```\n\nAlways pass `api_name=\"storage\"` explicitly \u2014 the default will change in a future release. Use `source_dict={\"blank\": {}}` for empty disks, or `source_dict={\"pvc\": {\"name\": \"source-pvc\", \"namespace\": \"default\"}}` for cloning.\n\n- **Clone a DataVolume:**\n ```python\n dv_clone = DataVolume(\n client=client,\n name=\"fedora-disk-clone\",\n namespace=\"default\",\n source_dict={\"pvc\": {\"name\": \"fedora-disk\", \"namespace\": \"default\"}},\n size=\"30Gi\",\n storage_class=\"ocs-storagecluster-ceph-rbd-virtualization\",\n access_modes=DataVolume.AccessMode.RWX,\n volume_mode=DataVolume.VolumeMode.BLOCK,\n api_name=\"storage\",\n )\n ```\n\n> **Tip:** See [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html) for full VM lifecycle recipes.\n\n---\n\n## Create a NetworkPolicy\n\nRestrict traffic to pods matching a label selector.\n\n```python\nfrom ocp_resources.network_policy import NetworkPolicy\n\nnetpol = NetworkPolicy(\n client=client,\n name=\"allow-http-only\",\n namespace=\"default\",\n pod_selector={\"matchLabels\": {\"app\": \"web-app\"}},\n policy_types=[\"Ingress\"],\n ingress=[\n {\n \"ports\": [{\"protocol\": \"TCP\", \"port\": 80}],\n \"from\": [\n {\"namespaceSelector\": {\"matchLabels\": {\"env\": \"production\"}}},\n ],\n }\n ],\n)\nnetpol.deploy()\n```\n\nThis allows TCP port 80 ingress only from namespaces labeled `env=production`. Omit `ingress` and set `policy_types=[\"Ingress\"]` to deny all inbound traffic.\n\n---\n\n## Create a NetworkAttachmentDefinition\n\nAttach pods to a secondary network using Multus.\n\n```python\nfrom ocp_resources.network_attachment_definition import LinuxBridgeNetworkAttachmentDefinition\n\nnad = LinuxBridgeNetworkAttachmentDefinition(\n client=client,\n name=\"br-100\",\n namespace=\"default\",\n bridge_name=\"br-100\",\n cni_type=\"cnv-bridge\",\n vlan=100,\n mtu=1500,\n)\nnad.deploy()\n```\n\nVariant classes are available for different bridge types:\n- `LinuxBridgeNetworkAttachmentDefinition` \u2014 Linux bridge (cnv-bridge)\n- `OvsBridgeNetworkAttachmentDefinition` \u2014 OVS bridge\n- `OVNOverlayNetworkAttachmentDefinition` \u2014 OVN overlay (layer 2/3)\n\n---\n\n## Create an RBAC Role and RoleBinding\n\nGrant specific permissions to a service account within a namespace.\n\n```python\nfrom ocp_resources.role import Role\nfrom ocp_resources.role_binding import RoleBinding\nfrom ocp_resources.service_account import ServiceAccount\n\nsa = ServiceAccount(\n client=client,\n name=\"app-sa\",\n namespace=\"default\",\n)\nsa.deploy()\n\nrole = Role(\n client=client,\n name=\"pod-reader\",\n namespace=\"default\",\n rules=[\n {\n \"apiGroups\": [\"\"],\n \"resources\": [\"pods\", \"pods/log\"],\n \"verbs\": [\"get\", \"list\", \"watch\"],\n }\n ],\n)\nrole.deploy()\n\nbinding = RoleBinding(\n client=client,\n name=\"app-sa-pod-reader\",\n namespace=\"default\",\n subjects_kind=\"ServiceAccount\",\n subjects_name=\"app-sa\",\n subjects_namespace=\"default\",\n role_ref_kind=\"Role\",\n role_ref_name=\"pod-reader\",\n)\nbinding.deploy()\n```\n\nThis grants the `app-sa` service account read-only access to pods in the `default` namespace.\n\n---\n\n## Create a ClusterRole and ClusterRoleBinding\n\nGrant cluster-wide permissions to a service account.\n\n```python\nfrom ocp_resources.cluster_role import ClusterRole\nfrom ocp_resources.cluster_role_binding import ClusterRoleBinding\n\ncr = ClusterRole(\n client=client,\n name=\"node-viewer\",\n rules=[\n {\n \"apiGroups\": [\"\"],\n \"resources\": [\"nodes\"],\n \"verbs\": [\"get\", \"list\", \"watch\"],\n }\n ],\n)\ncr.deploy()\n\ncrb = ClusterRoleBinding(\n client=client,\n name=\"app-sa-node-viewer\",\n cluster_role=\"node-viewer\",\n subjects=[\n {\n \"kind\": \"ServiceAccount\",\n \"name\": \"app-sa\",\n \"namespace\": \"default\",\n }\n ],\n)\ncrb.deploy()\n```\n\n`ClusterRole` and `ClusterRoleBinding` are cluster-scoped (`Resource` subclasses), so no `namespace` parameter is needed on the role or binding itself.\n\n---\n\n## List and Filter Resources\n\nQuery existing resources using label and field selectors.\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.namespace import Namespace\n\n# List all pods in a namespace\nfor pod in Pod.get(client=client, namespace=\"default\"):\n print(f\"{pod.name} \u2014 {pod.status}\")\n\n# Filter by label\nfor pod in Pod.get(client=client, namespace=\"default\", label_selector=\"app=web-app\"):\n print(pod.name)\n\n# Filter by field\nfor pod in Pod.get(client=client, namespace=\"default\", field_selector=\"status.phase=Running\"):\n print(pod.name)\n\n# List cluster-scoped resources (no namespace)\nfor ns in Namespace.get(client=client, label_selector=\"env=staging\"):\n print(ns.name)\n```\n\nThe `get()` class method returns a generator of resource objects. Pass `raw=True` to get raw `ResourceInstance` objects instead.\n\n> **Tip:** See [Querying and Watching Resources](querying-resources.html) for advanced querying patterns.\n\n---\n\n## Create a Resource from a YAML File\n\nLoad a resource definition from an existing YAML file.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n client=client,\n yaml_file=\"manifests/my-pod.yaml\",\n)\npod.deploy()\n```\n\nWhen using `yaml_file`, the `name` and `namespace` are read from the file automatically. You cannot combine `yaml_file` with `kind_dict`.\n\n---\n\n## Create a Resource from a Dictionary\n\nPass a raw Kubernetes resource dict directly.\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndep = Deployment(\n client=client,\n kind_dict={\n \"apiVersion\": \"apps/v1\",\n \"kind\": \"Deployment\",\n \"metadata\": {\n \"name\": \"from-dict-app\",\n \"namespace\": \"default\",\n },\n \"spec\": {\n \"replicas\": 2,\n \"selector\": {\"matchLabels\": {\"app\": \"from-dict\"}},\n \"template\": {\n \"metadata\": {\"labels\": {\"app\": \"from-dict\"}},\n \"spec\": {\n \"containers\": [\n {\n \"name\": \"app\",\n \"image\": \"nginx:1.25\",\n }\n ],\n },\n },\n },\n },\n)\ndep.deploy()\n```\n\nUsing `kind_dict` bypasses all constructor logic in `to_dict()` \u2014 the dictionary is sent as-is to the API. This is useful when migrating existing manifests.\n\n---\n\n## Handle Resources with Duplicate API Groups\n\nSome Kubernetes resource kinds (e.g., `DNS`, `Ingress`, `Gateway`) exist in multiple API groups. The wrapper provides separate modules for each variant.\n\n```python\n# DNS from config.openshift.io (cluster DNS config)\nfrom ocp_resources.dns_config_openshift_io import DNS as DNSConfig\n\ndns_config = DNSConfig(client=client, name=\"cluster\", ensure_exists=True)\nprint(dns_config.instance.spec.baseDomain)\n\n# DNS from operator.openshift.io (CoreDNS operator)\nfrom ocp_resources.dns_operator_openshift_io import DNS as DNSOperator\n\ndns_operator = DNSOperator(client=client, name=\"default\", ensure_exists=True)\nprint(dns_operator.instance.spec.logLevel)\n```\n\n```python\n# Ingress from config.openshift.io (cluster ingress config)\nfrom ocp_resources.ingress_config_openshift_io import Ingress as IngressConfig\n\ningress = IngressConfig(client=client, name=\"cluster\", ensure_exists=True)\nprint(ingress.instance.spec.domain)\n\n# Ingress from networking.k8s.io (K8s Ingress rules)\nfrom ocp_resources.ingress_networking_k8s_io import Ingress as K8sIngress\n\nk8s_ingress = K8sIngress(\n client=client,\n name=\"my-app-ingress\",\n rules=[\n {\n \"host\": \"app.example.com\",\n \"http\": {\n \"paths\": [\n {\n \"path\": \"/\",\n \"pathType\": \"Prefix\",\n \"backend\": {\"service\": {\"name\": \"web-app-svc\", \"port\": {\"number\": 80}}},\n }\n ],\n },\n }\n ],\n)\n```\n\nThe naming convention for duplicate-kind modules is `_.py`. Use Python import aliases (`as`) to distinguish them in your code.\n\n**Common duplicate kinds and their modules:**\n\n| Kind | Module | API Group |\n|------|--------|-----------|\n| `DNS` | `dns_config_openshift_io` | `config.openshift.io` |\n| `DNS` | `dns_operator_openshift_io` | `operator.openshift.io` |\n| `Ingress` | `ingress_config_openshift_io` | `config.openshift.io` |\n| `Ingress` | `ingress_networking_k8s_io` | `networking.k8s.io` |\n| `Gateway` | `gateway` | `networking.istio.io` |\n| `Gateway` | `gateway_gateway_networking_k8s_io` | `gateway.networking.k8s.io` |\n| `Gateway` | `gateway_networking_istio_io` | `networking.istio.io` |\n\n> **Warning:** Importing the wrong module will target the wrong API group, causing `NotFoundError` or unexpected behavior. Always verify the `api_group` attribute on your resource class.\n\n---\n\n## Manage Multiple Similar Resources with ResourceList\n\nCreate N copies of a resource with auto-numbered names.\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import ResourceList\n\nwith ResourceList(\n resource_class=Namespace,\n num_resources=3,\n client=client,\n name=\"perf-test-ns\",\n) as namespaces:\n # Creates: perf-test-ns-1, perf-test-ns-2, perf-test-ns-3\n for ns in namespaces:\n print(ns.name)\n# All 3 namespaces are deleted on exit\n```\n\n> **Tip:** See [Managing Bulk Resources with ResourceList](managing-resource-lists.html) for `NamespacedResourceList` and other bulk patterns.\n\n---\n\n## Deploy One Resource Per Namespace with NamespacedResourceList\n\nCreate an identical namespaced resource in each of several namespaces.\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.config_map import ConfigMap\nfrom ocp_resources.resource import ResourceList, NamespacedResourceList\n\nwith ResourceList(\n resource_class=Namespace,\n num_resources=3,\n client=client,\n name=\"team-ns\",\n) as namespaces:\n with NamespacedResourceList(\n resource_class=ConfigMap,\n namespaces=namespaces,\n client=client,\n name=\"shared-config\",\n data={\"REGION\": \"us-east-1\"},\n ) as configmaps:\n # One ConfigMap \"shared-config\" in each of team-ns-1, team-ns-2, team-ns-3\n for cm in configmaps:\n print(f\"{cm.namespace}/{cm.name}\")\n```\n\n`NamespacedResourceList` requires a `ResourceList` of `Namespace` objects. All resources are cleaned up in reverse order on exit.\n\n---\n\n## Temporarily Edit a Resource with ResourceEditor\n\nApply temporary patches during a test and automatically restore original values.\n\n```python\nfrom ocp_resources.resource import ResourceEditor\nfrom ocp_resources.config_map import ConfigMap\n\ncm = ConfigMap(client=client, name=\"app-config\", namespace=\"default\", ensure_exists=True)\n\nwith ResourceEditor(\n patches={cm: {\"data\": {\"LOG_LEVEL\": \"debug\"}}},\n action=\"update\",\n):\n # LOG_LEVEL is now \"debug\"\n print(cm.instance.data.LOG_LEVEL)\n# Original LOG_LEVEL is restored here\n```\n\n`ResourceEditor` backs up original values on enter and restores them on exit. Use `action=\"replace\"` when you need to remove fields entirely rather than patch them.\n\n> **Tip:** See [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html) for complete details.\n\n---\n\n## Validate a Resource Before Creating It\n\nCatch schema errors before submitting to the API server.\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ValidationError\n\npod = Pod(\n client=client,\n name=\"validated-pod\",\n namespace=\"default\",\n containers=[\n {\n \"name\": \"app\",\n \"image\": \"nginx:1.25\",\n }\n ],\n schema_validation_enabled=True, # Auto-validate on create()\n)\n\n# Manual validation\ntry:\n pod.validate()\n print(\"Resource is valid\")\nexcept ValidationError as e:\n print(f\"Validation failed: {e}\")\n\n# Or validate a raw dict without instantiation\ntry:\n Pod.validate_dict({\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"test\"},\n \"spec\": {\"containers\": [{\"name\": \"app\", \"image\": \"nginx\"}]},\n })\nexcept ValidationError as e:\n print(f\"Dict validation failed: {e}\")\n```\n\nWhen `schema_validation_enabled=True`, `create()` and `update_replace()` automatically validate before sending to the API. Validation requires OpenAPI schemas to be available.\n\n> **Tip:** See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for schema setup and troubleshooting.\n\n---\n\n## Check If a Resource Exists\n\nVerify a resource exists before operating on it.\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndep = Deployment(client=client, name=\"web-app\", namespace=\"default\")\n\nif dep.exists:\n print(f\"Deployment exists, status: {dep.instance.status.availableReplicas} replicas available\")\nelse:\n print(\"Deployment not found\")\n```\n\n`exists` returns the resource instance if found or `None` if not. For fail-fast behavior, use `ensure_exists=True` in the constructor \u2014 it raises `ResourceNotFoundError` immediately if the resource is missing.\n\n---\n\n## Watch Resource Events\n\nStream events related to a specific resource.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-nginx\", namespace=\"default\", ensure_exists=True)\n\n# Watch events for this pod (10 second window)\nfor event in pod.events(timeout=10):\n ev = event[\"object\"]\n print(f\"[{ev.type}] {ev.reason}: {ev.message}\")\n```\n\nUse `field_selector` to narrow results further, for example: `field_selector=\"type==Warning\"`. The `events()` method automatically filters by `involvedObject.name`.\n\n---\n\n## List Recent Events in a Namespace\n\nGet existing events (not a watch stream) from the last N seconds.\n\n```python\nfrom ocp_resources.event import Event\n\n# Warning events from the last 5 minutes\nevents = Event.list(\n client=client,\n namespace=\"default\",\n field_selector=\"type==Warning\",\n since_seconds=300,\n)\nfor ev in events:\n print(f\"[{ev.reason}] {ev.message}\")\n```\n\n`Event.list()` returns a sorted list (most recent first), unlike `Event.get()` which is a live watch stream.\n\n---\n\n## Wait for a Resource Condition\n\nBlock until a resource reaches a specific condition.\n\n```python\nfrom ocp_resources.deployment import Deployment\n\ndep = Deployment(client=client, name=\"web-app\", namespace=\"default\", ensure_exists=True)\n\ndep.wait_for_condition(\n condition=\"Available\",\n status=\"True\",\n timeout=300,\n)\n```\n\nUse `stop_condition` to fail fast if an unrecoverable condition is detected:\n\n```python\ndep.wait_for_condition(\n condition=\"Available\",\n status=\"True\",\n timeout=300,\n stop_condition=\"ReplicaFailure\",\n stop_status=\"True\",\n)\n```\n\n> **Tip:** See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for advanced waiting patterns.\n\n---\n\n## Get a Resource's YAML Representation\n\nDump the intended resource dict as YAML (useful for debugging).\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n client=client,\n name=\"debug-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"app\", \"image\": \"busybox\", \"command\": [\"sleep\", \"3600\"]}],\n)\nprint(pod.to_yaml())\n```\n\n`to_yaml()` calls `to_dict()` internally and returns the YAML string. This shows what would be sent to the API, not the live state.\n\n---\n\n## Get the Pod's Node and IP\n\nAccess commonly needed runtime properties of a pod.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-nginx\", namespace=\"default\", ensure_exists=True)\n\n# Node where the pod is running\nprint(f\"Node: {pod.node.name}\")\n\n# Pod IP address\nprint(f\"IP: {pod.ip}\")\n```\n\nThe `node` property returns a `Node` resource object. The `ip` property reads from `status.podIP`.\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Querying and Watching Resources](querying-resources.html)\n- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html)\n- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html)\n- [Working with Kubernetes Events](working-with-events.html)\n"}, {"slug": "mcp-server-integration", "title": "Integrating with AI Assistants via MCP Server", "content": "# Integrating with AI Assistants via MCP Server\n\nThe openshift-python-wrapper ships with a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets AI assistants like Cursor, Claude Desktop, and other MCP-compatible tools directly manage your OpenShift/Kubernetes cluster resources.\n\n## Configure the MCP Server for Cursor\n\nConnect your Cursor IDE to your cluster so the AI assistant can list, create, update, and delete resources on your behalf.\n\n```json\n// ~/.cursor/mcp.json\n{\n \"mcpServers\": {\n \"openshift-python-wrapper\": {\n \"command\": \"openshift-mcp-server\"\n }\n }\n}\n```\n\nAfter saving, restart Cursor and reference the server with `@openshift-python-wrapper` in your prompts. The server uses your active kubeconfig context automatically.\n\n> **Note:** The `openshift-mcp-server` command is installed as a script entry point when you install the package. See [Installing and Creating Your First Resource](quickstart.html) for installation instructions.\n\n## Configure the MCP Server for Claude Desktop\n\nSet up Claude Desktop to interact with your cluster through natural language.\n\n```json\n// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json\n// Windows: %APPDATA%\\Claude\\claude_desktop_config.json\n// Linux: ~/.config/Claude/claude_desktop_config.json\n{\n \"mcpServers\": {\n \"openshift-python-wrapper\": {\n \"command\": \"openshift-mcp-server\"\n }\n }\n}\n```\n\nClaude Desktop will connect to the server on startup. You can then ask it to list pods, create resources, check logs, and more using natural language.\n\n## Configure a Custom Kubeconfig Path\n\nPoint the MCP server at a specific kubeconfig when your default location doesn't apply.\n\n```json\n{\n \"mcpServers\": {\n \"openshift-python-wrapper\": {\n \"command\": \"openshift-mcp-server\",\n \"env\": {\n \"KUBECONFIG\": \"/home/user/.kube/my-cluster-config\"\n }\n }\n }\n}\n```\n\nThe `KUBECONFIG` environment variable is passed to the server process at startup. This is useful when managing multiple clusters or when your kubeconfig is in a non-standard location.\n\n> **Tip:** See [Connecting to Clusters](connecting-to-clusters.html) for all authentication methods supported by openshift-python-wrapper, including tokens, basic auth, and in-cluster config.\n\n## Run the MCP Server from Source (Development)\n\nRun the server directly from a cloned repository for development or testing.\n\n```bash\ngit clone https://github.com/RedHatQE/openshift-python-wrapper.git\ncd openshift-python-wrapper\nuv run mcp_server/server.py\n```\n\nFor MCP client configuration when running from source:\n\n```json\n{\n \"mcpServers\": {\n \"openshift-python-wrapper\": {\n \"command\": \"uv\",\n \"args\": [\"run\", \"mcp_server/server.py\"],\n \"cwd\": \"/path/to/openshift-python-wrapper\"\n }\n }\n}\n```\n\nThis bypasses the installed entry point and runs the server module directly, which is useful when testing local changes.\n\n## List Cluster Resources via AI Chat\n\nAsk your AI assistant to list resources \u2014 the MCP server exposes a `list_resources` tool that supports type filtering, namespaces, label selectors, and limits.\n\n```\nPrompt: \"List all pods in the openshift-monitoring namespace with label app=prometheus\"\n```\n\nThe AI will call the `list_resources` tool, which maps to:\n\n```python\nlist_resources(resource_type=\"pod\", namespace=\"openshift-monitoring\", label_selector=\"app=prometheus\", limit=20)\n```\n\nReturned data includes name, namespace, UID, creation timestamp, labels, phase, and conditions for each matched resource.\n\n> **Tip:** Use `get_resource_types` to discover all available resource types. The server dynamically scans every module in `ocp_resources/` at startup, so any resource supported by the library \u2014 including OpenShift-specific types like `route`, `virtualmachine`, or `clusterserviceversion` \u2014 is available.\n\n## Get Detailed Resource Information\n\nRetrieve a single resource with full metadata, status, and optionally the raw YAML or JSON.\n\n```\nPrompt: \"Show me the deployment 'api-server' in namespace 'production' as YAML\"\n```\n\nThe AI calls:\n\n```python\nget_resource(\n resource_type=\"deployment\",\n name=\"api-server\",\n namespace=\"production\",\n output_format=\"yaml\", # also accepts \"json\" or \"info\"\n)\n```\n\nThe `info` format (default) returns structured metadata plus resource-specific details: for pods you get container statuses and node placement; for deployments you get replica counts; for services you get ports and cluster IP.\n\n## Create Resources Through Natural Language\n\nAsk the AI to create resources \u2014 it can use either YAML content or structured spec parameters.\n\n```\nPrompt: \"Create a ConfigMap called 'app-settings' in the 'staging' namespace with keys\ndatabase_host=db.staging.svc and log_level=debug\"\n```\n\nThe AI calls:\n\n```python\ncreate_resource(\n resource_type=\"configmap\",\n name=\"app-settings\",\n namespace=\"staging\",\n spec={\"data\": {\"database_host\": \"db.staging.svc\", \"log_level\": \"debug\"}},\n labels={\"managed-by\": \"mcp-server\"},\n)\n```\n\nYou can also provide full YAML:\n\n```python\ncreate_resource(\n resource_type=\"pod\",\n name=\"nginx\",\n yaml_content=\"\"\"\napiVersion: v1\nkind: Pod\nmetadata:\n name: nginx\n namespace: default\nspec:\n containers:\n - name: nginx\n image: nginx:1.27\n ports:\n - containerPort: 80\n\"\"\",\n)\n```\n\n> **Warning:** The MCP server creates real resources on your cluster. Make sure your AI assistant is targeting the correct cluster and namespace before confirming creation operations.\n\n## Apply Multi-Document YAML Manifests\n\nDeploy a complete application stack from a single YAML block containing multiple resources.\n\n```\nPrompt: \"Apply this manifest to create a namespace, deployment, and service for my app\"\n```\n\nThe AI calls:\n\n```python\napply_yaml(\n yaml_content=\"\"\"\n---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: my-app\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: backend\n namespace: my-app\nspec:\n replicas: 3\n selector:\n matchLabels:\n app: backend\n template:\n metadata:\n labels:\n app: backend\n spec:\n containers:\n - name: api\n image: myapp/backend:v1.0\n ports:\n - containerPort: 8080\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: backend\n namespace: my-app\nspec:\n selector:\n app: backend\n ports:\n - port: 80\n targetPort: 8080\n\"\"\"\n)\n```\n\nThe response includes a summary with `total_resources`, `successful`, and `failed` counts, plus per-resource results. Each YAML document is parsed and deployed independently.\n\n## Update Resources with Patches\n\nModify existing resources using merge or strategic-merge patches.\n\n```\nPrompt: \"Scale the 'web-frontend' deployment in 'production' to 5 replicas\"\n```\n\nThe AI calls:\n\n```python\nupdate_resource(\n resource_type=\"deployment\",\n name=\"web-frontend\",\n namespace=\"production\",\n patch={\"spec\": {\"replicas\": 5}},\n patch_type=\"merge\", # or \"strategic\"\n)\n```\n\nThe `patch_type` parameter controls the content type: `\"merge\"` uses `application/merge-patch+json`, while `\"strategic\"` uses `application/strategic-merge-patch+json`.\n\n## Delete Resources\n\nRemove resources with optional wait-for-deletion behavior.\n\n```\nPrompt: \"Delete the pod 'debug-pod' in namespace 'default' and wait for it to be gone\"\n```\n\nThe AI calls:\n\n```python\ndelete_resource(resource_type=\"pod\", name=\"debug-pod\", namespace=\"default\", wait=True, timeout=60)\n```\n\nIf the resource doesn't exist, the server returns a success response with a warning rather than an error, making the operation idempotent.\n\n## Retrieve Pod Logs\n\nFetch logs from running or crashed pods with filtering options.\n\n```\nPrompt: \"Show me the last 200 lines of logs from pod 'api-server-7f8b9c' container 'app'\nin namespace 'production'\"\n```\n\nThe AI calls:\n\n```python\nget_pod_logs(name=\"api-server-7f8b9c\", namespace=\"production\", container=\"app\", tail_lines=200)\n```\n\n- Use `since_seconds=3600` to get logs from the last hour\n- Use `previous=True` to get logs from a crashed container's previous instance\n- Omit `container` for single-container pods\n\n## Execute Commands in Pods\n\nRun diagnostic commands inside a running pod container.\n\n```\nPrompt: \"Check the nginx config in pod 'web-server' namespace 'production'\"\n```\n\nThe AI calls:\n\n```python\nexec_in_pod(name=\"web-server\", namespace=\"production\", command=[\"nginx\", \"-t\"], container=\"nginx\")\n```\n\nThe response includes `stdout`, `stderr`, and `returncode`. If the command fails, the exit code and error output are captured rather than raising an exception.\n\n> **Warning:** Pod exec gives shell-level access to your containers. Ensure your RBAC policies restrict which service accounts and users can perform `exec` operations.\n\n## Get Resource Events\n\nRetrieve Kubernetes events related to a specific resource for troubleshooting.\n\n```\nPrompt: \"Show me the events for pod 'crashloop-pod' in 'default' namespace\"\n```\n\nThe AI calls:\n\n```python\nget_resource_events(resource_type=\"pod\", name=\"crashloop-pod\", namespace=\"default\", limit=10)\n```\n\nEvents are filtered using field selectors on `involvedObject.name`, `involvedObject.namespace`, and `involvedObject.kind`. Each event includes type (Normal/Warning), reason, message, count, timestamps, and source component.\n\n## Troubleshoot a Failing Pod (End-to-End Workflow)\n\nCombine multiple MCP tools in sequence to diagnose pod issues \u2014 the AI assistant will chain these calls automatically.\n\n```\nPrompt: \"Pod 'payment-svc-6d4f8' in namespace 'checkout' keeps crashing. Help me figure out why.\"\n```\n\nThe AI will typically execute this sequence:\n\n1. **Check status:** `get_resource(resource_type=\"pod\", name=\"payment-svc-6d4f8\", namespace=\"checkout\")` \u2014 gets phase, container statuses, restart count\n2. **Review events:** `get_resource_events(resource_type=\"pod\", name=\"payment-svc-6d4f8\", namespace=\"checkout\")` \u2014 shows scheduling, pulling, crash events\n3. **Read current logs:** `get_pod_logs(name=\"payment-svc-6d4f8\", namespace=\"checkout\", tail_lines=100)` \u2014 application output before crash\n4. **Read previous crash logs:** `get_pod_logs(name=\"payment-svc-6d4f8\", namespace=\"checkout\", previous=True)` \u2014 logs from the last terminated container\n5. **Inspect config:** `exec_in_pod(name=\"payment-svc-6d4f8\", namespace=\"checkout\", command=[\"cat\", \"/etc/app/config.yaml\"])` \u2014 verify mounted configuration\n\nThe AI then synthesizes all the data into a diagnosis with suggested fixes.\n\n## Write a Programmatic MCP Client\n\nConnect to the MCP server programmatically using `FastMCPClient` for automation scripts.\n\n```python\nimport asyncio\nfrom fastmcp import FastMCPClient\n\n\nasync def main():\n async with FastMCPClient() as client:\n await client.connect_stdio(cmd=[\"openshift-mcp-server\"])\n\n # Discover available resource types\n types = await client.call_tool(\n name=\"get_resource_types\",\n arguments={\"random_string\": \"x\"},\n )\n print(f\"Available types: {types['total_count']}\")\n\n # List pods in a namespace\n pods = await client.call_tool(\n name=\"list_resources\",\n arguments={\"resource_type\": \"pod\", \"namespace\": \"default\", \"limit\": 5},\n )\n for pod in pods[\"resources\"]:\n print(f\" {pod['name']} \u2014 {pod.get('phase', 'Unknown')}\")\n\n\nif __name__ == \"__main__\":\n asyncio.run(main())\n```\n\nThis uses the same stdio transport that AI assistants use. The `FastMCPClient` is provided by the `fastmcp` library, which is already a dependency of openshift-python-wrapper.\n\n## Debug MCP Server Issues\n\nEnable debug logging and inspect the server log file when tools aren't behaving as expected.\n\n```bash\n# Check the MCP server log file\ntail -f /tmp/mcp_server_debug.log\n```\n\n```bash\n# Run with debug-level logging from the simple-logger\nSIMPLE_LOGGER_LEVEL=DEBUG openshift-mcp-server\n```\n\nThe server writes debug logs to `/tmp/mcp_server_debug.log` by default, including every resource scan at startup, client creation events, and detailed error tracebacks.\n\n- Verify cluster connectivity independently: `kubectl cluster-info`\n- Check your permissions: `kubectl auth can-i --list`\n- Confirm the entry point is installed: `which openshift-mcp-server`\n\n> **Tip:** See [Environment Variables and Configuration](environment-variables.html) for other environment variables that affect library behavior.\n\n## Available MCP Tools Reference\n\nThe MCP server exposes ten tools. All resource type names are case-insensitive (e.g., `\"Pod\"`, `\"pod\"`, and `\"POD\"` all work).\n\n| Tool | Purpose | Required Parameters |\n|------|---------|-------------------|\n| `list_resources` | List resources by type with optional filters | `resource_type` |\n| `get_resource` | Get a single resource by name | `resource_type`, `name` |\n| `create_resource` | Create a resource from spec or YAML | `resource_type`, `name`, plus `spec` or `yaml_content` |\n| `update_resource` | Patch an existing resource | `resource_type`, `name`, `patch` |\n| `delete_resource` | Delete a resource | `resource_type`, `name` |\n| `get_pod_logs` | Retrieve pod container logs | `name`, `namespace` |\n| `exec_in_pod` | Execute a command in a pod | `name`, `namespace`, `command` |\n| `get_resource_events` | Get events for a resource | `resource_type`, `name` |\n| `apply_yaml` | Apply one or more YAML documents | `yaml_content` |\n| `get_resource_types` | List all discovered resource types | `random_string` (any value) |\n\n> **Note:** Namespaced resources (pods, deployments, configmaps, etc.) require a `namespace` parameter. Cluster-scoped resources (nodes, namespaces, cluster roles) do not. The server validates this automatically and returns a clear error if a namespace is missing.\n\n## Related Pages\n\n- [Connecting to Clusters](connecting-to-clusters.html)\n- [Installing and Creating Your First Resource](quickstart.html)\n- [Environment Variables and Configuration](environment-variables.html)\n- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html)\n- [Querying and Watching Resources](querying-resources.html)\n"}, {"slug": "resource-api", "title": "Resource and NamespacedResource API", "content": "# Resource and NamespacedResource API\n\nComplete API reference for the `Resource` and `NamespacedResource` base classes, the `get_client` factory function, and supporting classes (`ResourceEditor`, `ResourceList`, `NamespacedResourceList`, `KubeAPIVersion`).\n\n**Import path:**\n\n```python\nfrom ocp_resources.resource import (\n Resource,\n NamespacedResource,\n ResourceEditor,\n ResourceList,\n NamespacedResourceList,\n get_client,\n)\n```\n\n---\n\n## `get_client`\n\nFactory function to obtain a Kubernetes `DynamicClient`.\n\n```python\nfrom ocp_resources.resource import get_client\n\nclient = get_client(config_file=\"~/.kube/config\")\n```\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `config_file` | `str \\| None` | `None` | Path to a kubeconfig file. Falls back to `KUBECONFIG` env var or `~/.kube/config`. |\n| `config_dict` | `dict[str, Any] \\| None` | `None` | Dict with kubeconfig configuration. Mutually exclusive with `config_file`. |\n| `context` | `str \\| None` | `None` | Name of the kubeconfig context to use. |\n| `client_configuration` | `kubernetes.client.Configuration \\| None` | `None` | Pre-built Kubernetes client configuration object. |\n| `persist_config` | `bool` | `True` | Whether to persist config file changes. |\n| `temp_file_path` | `str \\| None` | `None` | Path to a temporary kubeconfig file. |\n| `try_refresh_token` | `bool` | `True` | Try to refresh the authentication token. |\n| `username` | `str \\| None` | `None` | Username for basic auth. Requires `password` and `host`. |\n| `password` | `str \\| None` | `None` | Password for basic auth. Requires `username` and `host`. |\n| `host` | `str \\| None` | `None` | Cluster host URL. |\n| `verify_ssl` | `bool \\| None` | `None` | Whether to verify SSL certificates. |\n| `token` | `str \\| None` | `None` | Bearer token for authentication. Requires `host`. |\n| `fake` | `bool` | `False` | Return a `FakeDynamicClient` for testing without a cluster. |\n| `generate_kubeconfig` | `bool` | `False` | Save the resolved kubeconfig to a temp file and attach the path to the client. |\n\n**Returns:** `DynamicClient | FakeDynamicClient`\n\n```python\n# Default kubeconfig\nclient = get_client()\n\n# With explicit config file and context\nclient = get_client(config_file=\"/path/to/kubeconfig\", context=\"my-cluster\")\n\n# Token-based auth\nclient = get_client(host=\"https://api.cluster.example.com:6443\", token=\"sha256~abc123\")\n\n# Basic auth\nclient = get_client(host=\"https://api.cluster.example.com:6443\", username=\"admin\", password=\"secret\")\n\n# Fake client for unit testing\nclient = get_client(fake=True)\n```\n\n> **Note:** If neither `config_file` nor `config_dict` is provided, the client falls back to the `KUBECONFIG` environment variable, then `~/.kube/config`, and finally in-cluster configuration.\n\nSee [Connecting to Clusters](connecting-to-clusters.html) for connection patterns. See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for `fake=True` usage.\n\n---\n\n## `Resource`\n\n```python\nfrom ocp_resources.resource import Resource\n```\n\nBase class for **cluster-scoped** Kubernetes/OpenShift resources (e.g., `Namespace`, `Node`, `ClusterRole`). Inherits from `ResourceConstants`. All concrete resource classes inherit from either `Resource` or `NamespacedResource`.\n\nSee [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) for the inheritance model.\n\n### Class Attributes\n\n| Attribute | Type | Default | Description |\n|---|---|---|---|\n| `api_group` | `str` | `\"\"` | API group for the resource (e.g., `\"apps\"`, `\"batch\"`). |\n| `api_version` | `str` | `\"\"` | Full API version string (e.g., `\"v1\"`, `\"apps/v1\"`). Resolved automatically if `api_group` is set. |\n| `singular_name` | `str` | `\"\"` | Singular resource name for API calls. Used to disambiguate when multiple resources match the same kind. |\n| `timeout_seconds` | `int` | `60` | Default timeout for API list/watch operations. |\n\n### Constructor\n\n```python\nResource(\n client=client,\n name=\"my-resource\",\n)\n```\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `client` | `DynamicClient \\| None` | `None` | Kubernetes dynamic client. Will be mandatory in the next major release. |\n| `name` | `str \\| None` | `None` | Resource name. Required unless `yaml_file` or `kind_dict` is provided. |\n| `teardown` | `bool` | `True` | Whether this resource should be deleted when used as a context manager. |\n| `yaml_file` | `str \\| None` | `None` | Path to a YAML file defining the resource. Mutually exclusive with `kind_dict`. |\n| `delete_timeout` | `int` | `240` | Timeout in seconds for delete operations. |\n| `dry_run` | `bool` | `False` | If `True`, create operations use dry-run mode. |\n| `node_selector` | `dict[str, Any] \\| None` | `None` | Node selector for scheduling. |\n| `node_selector_labels` | `dict[str, str] \\| None` | `None` | Node selector labels for scheduling. |\n| `config_file` | `str \\| None` | `None` | Path to kubeconfig. Deprecated; pass `client` instead. |\n| `config_dict` | `dict[str, Any] \\| None` | `None` | Kubeconfig dict. |\n| `context` | `str \\| None` | `None` | Kubeconfig context name. Deprecated; pass `client` instead. |\n| `label` | `dict[str, str] \\| None` | `None` | Labels to set on the resource. |\n| `annotations` | `dict[str, str] \\| None` | `None` | Annotations to set on the resource. |\n| `api_group` | `str` | `\"\"` | Override the class-level `api_group`. |\n| `hash_log_data` | `bool` | `True` | Hash sensitive fields (defined by `keys_to_hash`) in log output. |\n| `ensure_exists` | `bool` | `False` | Check that the resource already exists on the cluster at init time. Raises `ResourceNotFoundError` if not. |\n| `kind_dict` | `dict[Any, Any] \\| None` | `None` | Complete resource dict. Mutually exclusive with `yaml_file`. Bypasses `to_dict()` logic. |\n| `wait_for_resource` | `bool` | `False` | When used as a context manager, wait for the resource to exist after creation. |\n| `schema_validation_enabled` | `bool` | `False` | Enable automatic OpenAPI schema validation on `create()` and `update_replace()`. |\n\n**Raises:**\n\n| Exception | Condition |\n|---|---|\n| `ValueError` | Both `yaml_file` and `kind_dict` are provided. |\n| `NotImplementedError` | Neither `api_group` nor `api_version` is defined on the class. |\n| `MissingRequiredArgumentError` | None of `name`, `yaml_file`, or `kind_dict` is provided. |\n| `ResourceNotFoundError` | `ensure_exists=True` and the resource does not exist. |\n\n```python\nfrom ocp_resources.namespace import Namespace\n\n# Basic creation\nns = Namespace(client=client, name=\"my-namespace\")\n\n# From a YAML file\nns = Namespace(client=client, yaml_file=\"namespace.yaml\")\n\n# From a dict\nns = Namespace(client=client, kind_dict={\"apiVersion\": \"v1\", \"kind\": \"Namespace\", \"metadata\": {\"name\": \"test\"}})\n\n# With labels and annotations\nns = Namespace(client=client, name=\"my-ns\", label={\"env\": \"test\"}, annotations={\"owner\": \"team-a\"})\n\n# Verify it already exists\nns = Namespace(client=client, name=\"default\", ensure_exists=True)\n```\n\n---\n\n### Context Manager Protocol\n\n`Resource` supports the Python context manager protocol for automatic creation and cleanup.\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nwith Namespace(client=client, name=\"temp-ns\") as ns:\n # Resource is created on __enter__\n print(ns.name)\n# Resource is deleted on __exit__ (if teardown=True)\n```\n\n| Method | Description |\n|---|---|\n| `__enter__()` | Calls `deploy(wait=self.wait_for_resource)`. Registers a `SIGINT` handler on the main thread to ensure cleanup on Ctrl+C. Returns `self`. |\n| `__exit__(...)` | Calls `clean_up()` if `teardown=True`. Raises `ResourceTeardownError` if cleanup fails. |\n\n> **Tip:** Set `teardown=False` to prevent automatic deletion on context exit.\n\n---\n\n### CRUD Methods\n\n#### `create`\n\n```python\ndef create(\n self,\n wait: bool = False,\n exceptions_dict: dict[type[Exception], list[str]] = ...,\n) -> ResourceInstance | None\n```\n\nCreate the resource on the cluster.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `wait` | `bool` | `False` | Wait for the resource to exist after creation. |\n| `exceptions_dict` | `dict[type[Exception], list[str]]` | `DEFAULT_CLUSTER_RETRY_EXCEPTIONS \\| PROTOCOL_ERROR_EXCEPTION_DICT` | Exceptions to retry on. |\n\n**Returns:** `ResourceInstance | None`\n\n**Raises:** `ValidationError` if `schema_validation_enabled=True` and the resource dict is invalid.\n\n```python\nns = Namespace(client=client, name=\"my-ns\")\nns.create(wait=True)\n```\n\n#### `delete`\n\n```python\ndef delete(\n self,\n wait: bool = False,\n timeout: int = 240,\n body: dict[str, Any] | None = None,\n) -> bool\n```\n\nDelete the resource from the cluster.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `wait` | `bool` | `False` | Wait for the resource to be fully deleted. |\n| `timeout` | `int` | `240` | Timeout in seconds when `wait=True`. |\n| `body` | `dict[str, Any] \\| None` | `None` | Optional delete options body. |\n\n**Returns:** `bool` \u2014 `True` if deleted or not found; `False` only if wait timed out.\n\n```python\nns.delete(wait=True, timeout=120)\n```\n\n#### `update`\n\n```python\ndef update(self, resource_dict: dict[str, Any]) -> None\n```\n\nPatch the resource with a merge-patch (`application/merge-patch+json`). Only updates the fields present in `resource_dict`.\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_dict` | `dict[str, Any]` | Partial resource dictionary with fields to update. |\n\n> **Note:** Schema validation is **not** applied on `update()` because patches are partial and would fail full-schema validation.\n\n```python\nns.update(resource_dict={\"metadata\": {\"labels\": {\"env\": \"staging\"}}})\n```\n\n#### `update_replace`\n\n```python\ndef update_replace(self, resource_dict: dict[str, Any]) -> None\n```\n\nReplace the full resource. Use this to **remove** existing fields (unlike `update()`, which only adds/modifies).\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_dict` | `dict[str, Any]` | Complete resource dictionary to replace with. |\n\n**Raises:** `ValidationError` if `schema_validation_enabled=True` and the dict is invalid.\n\n```python\nfull_dict = ns.instance.to_dict()\nfull_dict[\"metadata\"][\"labels\"] = {\"new-label\": \"only\"}\nns.update_replace(resource_dict=full_dict)\n```\n\n---\n\n### Deploy / Clean Up\n\n#### `deploy`\n\n```python\ndef deploy(self, wait: bool = False) -> Self\n```\n\nCreate the resource (respects `REUSE_IF_RESOURCE_EXISTS` environment variable).\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `wait` | `bool` | `False` | Wait for the resource after creation. |\n\n**Returns:** `Self`\n\nSee [Environment Variables and Configuration](environment-variables.html) for `REUSE_IF_RESOURCE_EXISTS` details.\n\n#### `clean_up`\n\n```python\ndef clean_up(self, wait: bool = True, timeout: int | None = None) -> bool\n```\n\nDelete the resource (respects `SKIP_RESOURCE_TEARDOWN` environment variable).\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `wait` | `bool` | `True` | Wait for deletion to complete. |\n| `timeout` | `int \\| None` | `None` | Timeout in seconds. Defaults to `delete_timeout`. |\n\n**Returns:** `bool` \u2014 `True` if deleted successfully.\n\nSee [Environment Variables and Configuration](environment-variables.html) for `SKIP_RESOURCE_TEARDOWN` details.\n\n---\n\n### Query Methods and Properties\n\n#### `exists`\n\n```python\n@property\ndef exists(self) -> ResourceInstance | None\n```\n\nCheck if the resource exists on the cluster.\n\n**Returns:** `ResourceInstance` if found, `None` if not.\n\n```python\nif ns.exists:\n print(\"Namespace exists\")\n```\n\n#### `instance`\n\n```python\n@property\ndef instance(self) -> ResourceInstance\n```\n\nGet the live resource instance from the cluster. Retries on transient cluster errors.\n\n**Returns:** `ResourceInstance`\n\n**Raises:** `NotFoundError` if the resource does not exist.\n\n```python\nresource_version = ns.instance.metadata.resourceVersion\n```\n\n#### `status`\n\n```python\n@property\ndef status(self) -> str\n```\n\nGet `status.phase` from the resource instance.\n\n**Returns:** `str` \u2014 The status phase string (e.g., `\"Running\"`, `\"Active\"`, `\"Pending\"`).\n\n```python\nprint(ns.status) # \"Active\"\n```\n\n#### `labels`\n\n```python\n@property\ndef labels(self) -> ResourceField\n```\n\nGet resource labels from `metadata.labels`.\n\n**Returns:** `ResourceField`\n\n```python\nfor key, value in ns.labels.items():\n print(f\"{key}={value}\")\n```\n\n#### `kind`\n\n```python\n@ClassProperty\ndef kind(cls) -> str | None\n```\n\nGet the resource kind name derived from the class hierarchy. This is a **class-level property** \u2014 accessible on both the class and instances.\n\n**Returns:** `str | None`\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nprint(Namespace.kind) # \"Namespace\"\n```\n\n#### `api`\n\n```python\n@property\ndef api(self) -> ResourceInstance\n```\n\nGet the resource API object (shortcut for `full_api()` with no extra kwargs).\n\n**Returns:** `ResourceInstance`\n\n#### `full_api`\n\n```python\ndef full_api(self, **kwargs: Any) -> ResourceInstance\n```\n\nGet the resource API object with optional filtering kwargs.\n\n| Keyword Argument | Description |\n|---|---|\n| `pretty` | Pretty-print output. |\n| `_continue` | Continuation token for list pagination. |\n| `field_selector` | Filter by field. |\n| `label_selector` | Filter by label. |\n| `limit` | Maximum number of results. |\n| `resource_version` | Filter by resource version. |\n| `timeout_seconds` | Request timeout. |\n| `watch` | Enable watch mode. |\n\n**Returns:** `ResourceInstance`\n\n---\n\n### Class Method: `get`\n\n```python\n@classmethod\ndef get(\n cls,\n client: DynamicClient | None = None,\n dyn_client: DynamicClient | None = None,\n config_file: str = \"\",\n singular_name: str = \"\",\n exceptions_dict: dict[type[Exception], list[str]] = ...,\n raw: bool = False,\n context: str | None = None,\n *args: Any,\n **kwargs: Any,\n) -> Generator[Any, None, None]\n```\n\nList resources of this kind from the cluster.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `client` | `DynamicClient \\| None` | `None` | Kubernetes client. |\n| `dyn_client` | `DynamicClient \\| None` | `None` | Deprecated alias for `client`. |\n| `config_file` | `str` | `\"\"` | Path to kubeconfig. Deprecated; pass `client`. |\n| `singular_name` | `str` | `\"\"` | Singular resource name for disambiguation. |\n| `exceptions_dict` | `dict` | `DEFAULT_CLUSTER_RETRY_EXCEPTIONS` | Exceptions to retry. |\n| `raw` | `bool` | `False` | If `True`, yield raw `ResourceInstance` objects instead of wrapper instances. |\n| `context` | `str \\| None` | `None` | Kubeconfig context. Deprecated; pass `client`. |\n| `**kwargs` | | | Passed through to the API (e.g., `label_selector`, `field_selector`). |\n\n**Returns:** `Generator` of resource instances.\n\n> **Note:** For `Resource` (cluster-scoped), yielded objects are constructed with `name` only. For `NamespacedResource`, yielded objects include both `name` and `namespace`.\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nfor ns in Namespace.get(client=client):\n print(ns.name)\n\n# With label selector\nfor ns in Namespace.get(client=client, label_selector=\"env=production\"):\n print(ns.name)\n\n# Raw mode\nfor raw_ns in Namespace.get(client=client, raw=True):\n print(raw_ns.metadata.name, raw_ns.status.phase)\n```\n\nSee [Querying and Watching Resources](querying-resources.html) for advanced list/filter patterns.\n\n---\n\n### Static Method: `get_all_cluster_resources`\n\n```python\n@staticmethod\ndef get_all_cluster_resources(\n client: DynamicClient | None = None,\n config_file: str = \"\",\n context: str | None = None,\n config_dict: dict[str, Any] | None = None,\n *args: Any,\n **kwargs: Any,\n) -> Generator[ResourceField, None, None]\n```\n\nYield all resources across **all** API groups in the cluster.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `client` | `DynamicClient \\| None` | `None` | Kubernetes client. |\n| `**kwargs` | | | Passed to the API (e.g., `label_selector`). |\n\n**Yields:** `ResourceField`\n\n```python\nfor resource in Resource.get_all_cluster_resources(client=client, label_selector=\"app=myapp\"):\n print(f\"{resource.kind}: {resource.metadata.name}\")\n```\n\n---\n\n### Wait Methods\n\n#### `wait`\n\n```python\ndef wait(self, timeout: int = 240, sleep: int = 1) -> None\n```\n\nWait until the resource exists on the cluster.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `timeout` | `int` | `240` | Maximum wait time in seconds. |\n| `sleep` | `int` | `1` | Sleep interval between retries. |\n\n**Raises:** `TimeoutExpiredError` if the resource does not exist within the timeout.\n\n#### `wait_deleted`\n\n```python\ndef wait_deleted(self, timeout: int = 240) -> bool\n```\n\nWait until the resource no longer exists.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `timeout` | `int` | `240` | Maximum wait time in seconds. |\n\n**Returns:** `bool` \u2014 `True` if deleted, `False` if timed out.\n\n#### `wait_for_status`\n\n```python\ndef wait_for_status(\n self,\n status: str,\n timeout: int = 240,\n stop_status: str | None = None,\n sleep: int = 1,\n exceptions_dict: dict[type[Exception], list[str]] = ...,\n) -> None\n```\n\nWait for `status.phase` to reach the expected value.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `status` | `str` | \u2014 | Expected status phase string. |\n| `timeout` | `int` | `240` | Maximum wait time in seconds. |\n| `stop_status` | `str \\| None` | `None` | Stop and fail immediately if this status is reached. Defaults to `Status.FAILED`. |\n| `sleep` | `int` | `1` | Sleep interval between retries. |\n| `exceptions_dict` | `dict` | `PROTOCOL_ERROR_EXCEPTION_DICT \\| DEFAULT_CLUSTER_RETRY_EXCEPTIONS` | Exceptions to retry. |\n\n**Raises:** `TimeoutExpiredError` if the desired status is not reached.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)\n```\n\n#### `wait_for_condition`\n\n```python\ndef wait_for_condition(\n self,\n condition: str,\n status: str,\n timeout: int = 300,\n sleep_time: int = 1,\n reason: str | None = None,\n message: str = \"\",\n stop_condition: str | None = None,\n stop_status: str = \"True\",\n) -> None\n```\n\nWait for a specific condition to reach the desired state.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `condition` | `str` | \u2014 | Condition type name (e.g., `\"Ready\"`, `\"Available\"`). |\n| `status` | `str` | \u2014 | Expected condition status (e.g., `\"True\"`, `\"False\"`). |\n| `timeout` | `int` | `300` | Maximum wait time in seconds. |\n| `sleep_time` | `int` | `1` | Interval between retries. |\n| `reason` | `str \\| None` | `None` | Expected condition reason. If set, must match exactly. |\n| `message` | `str` | `\"\"` | Expected substring within the condition message. |\n| `stop_condition` | `str \\| None` | `None` | Condition type that, if matched, stops the wait and fails immediately. |\n| `stop_status` | `str` | `\"True\"` | Status for `stop_condition` matching. |\n\n**Raises:**\n\n| Exception | Condition |\n|---|---|\n| `TimeoutExpiredError` | Condition not met within timeout. |\n| `ConditionError` | `stop_condition` is detected with matching `stop_status`. |\n\n```python\nns.wait_for_condition(\n condition=\"Ready\",\n status=\"True\",\n timeout=60,\n)\n```\n\nSee [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for more patterns.\n\n#### `wait_for_conditions`\n\n```python\ndef wait_for_conditions(self) -> None\n```\n\nWait for the resource to have any conditions populated in its status. Uses a 30-second timeout.\n\n---\n\n### Serialization\n\n#### `to_dict`\n\n```python\ndef to_dict(self) -> None\n```\n\nPopulate `self.res` with the intended dict representation of the resource. Called automatically before `create()`. Override this in subclasses to add resource-specific fields.\n\n> **Note:** If `kind_dict` or `yaml_file` was provided, `to_dict()` uses those directly instead of building from individual parameters.\n\n#### `to_yaml`\n\n```python\ndef to_yaml(self) -> str\n```\n\n**Returns:** `str` \u2014 YAML string representation of the resource dict.\n\n```python\nprint(ns.to_yaml())\n```\n\n---\n\n### Watch / Events\n\n#### `watcher`\n\n```python\ndef watcher(\n self,\n timeout: int,\n resource_version: str = \"\",\n) -> Generator[dict[str, Any], None, None]\n```\n\nWatch for changes to this specific resource.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `timeout` | `int` | \u2014 | Watch duration in seconds. |\n| `resource_version` | `str` | `\"\"` | Only return events after this version. Defaults to the version at resource creation time. |\n\n**Yields:** Event dicts with keys `type` (`\"ADDED\"`, `\"MODIFIED\"`, `\"DELETED\"`), `raw_object`, and `object`.\n\n```python\nfor event in ns.watcher(timeout=30):\n print(event[\"type\"], event[\"object\"].metadata.name)\n```\n\n#### `events`\n\n```python\ndef events(\n self,\n name: str = \"\",\n label_selector: str = \"\",\n field_selector: str = \"\",\n resource_version: str = \"\",\n timeout: int = 240,\n) -> Generator[Any, Any, None]\n```\n\nGet Kubernetes events related to this resource.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `name` | `str` | `\"\"` | Filter by event name. |\n| `label_selector` | `str` | `\"\"` | Comma-separated `key=value` label filters. |\n| `field_selector` | `str` | `\"\"` | Additional field selectors (auto-prefixed with `involvedObject.name==`). |\n| `resource_version` | `str` | `\"\"` | Filter events by resource version. |\n| `timeout` | `int` | `240` | Timeout in seconds. |\n\n**Yields:** `Event` objects.\n\n```python\nfor event in pod.events(field_selector=\"type==Warning\", timeout=10):\n print(event.object)\n```\n\n---\n\n### Validation\n\n#### `validate`\n\n```python\ndef validate(self) -> None\n```\n\nValidate `self.res` against the OpenAPI schema for this resource kind. Called automatically during `create()` and `update_replace()` when `schema_validation_enabled=True`.\n\n**Raises:** `ValidationError` if the resource dict is invalid.\n\n```python\npod = Pod(client=client, name=\"test\", namespace=\"default\")\npod.to_dict()\npod.validate() # Raises ValidationError on invalid spec\n```\n\n#### `validate_dict` (classmethod)\n\n```python\n@classmethod\ndef validate_dict(cls, resource_dict: dict[str, Any]) -> None\n```\n\nValidate an arbitrary resource dictionary against the schema without creating a resource instance.\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_dict` | `dict[str, Any]` | Complete resource dictionary. |\n\n**Raises:** `ValidationError` if validation fails.\n\n```python\nfrom ocp_resources.pod import Pod\n\nPod.validate_dict({\n \"apiVersion\": \"v1\",\n \"kind\": \"Pod\",\n \"metadata\": {\"name\": \"test\"},\n \"spec\": {\"containers\": [{\"name\": \"web\", \"image\": \"nginx\"}]},\n})\n```\n\nSee [Validating Resources Against OpenAPI Schemas](validating-resources.html) for full validation guide.\n\n---\n\n### API Request\n\n#### `api_request`\n\n```python\ndef api_request(\n self,\n method: str,\n action: str,\n url: str,\n retry_params: dict[str, int] | None = None,\n **params: Any,\n) -> dict[str, Any]\n```\n\nSend a raw HTTP request to the resource's API endpoint. Used internally for subresource actions (e.g., VirtualMachine start/stop).\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `method` | `str` | \u2014 | HTTP method (`\"GET\"`, `\"PUT\"`, `\"POST\"`, etc.). |\n| `action` | `str` | \u2014 | Subresource action to append to the URL (e.g., `\"start\"`, `\"stop\"`). |\n| `url` | `str` | \u2014 | Base URL of the resource. |\n| `retry_params` | `dict[str, int] \\| None` | `None` | Dict with `timeout` and `sleep_time` keys for retry behavior. |\n| `**params` | | | Additional params passed to the HTTP request. |\n\n**Returns:** `dict[str, Any]` \u2014 Parsed JSON response, or raw response data if not valid JSON.\n\n---\n\n### Utility Methods\n\n#### `retry_cluster_exceptions` (static)\n\n```python\n@staticmethod\ndef retry_cluster_exceptions(\n func: Callable,\n exceptions_dict: dict[type[Exception], list[str]] = DEFAULT_CLUSTER_RETRY_EXCEPTIONS,\n timeout: int = 10,\n sleep_time: int = 1,\n **kwargs: Any,\n) -> Any\n```\n\nRetry a callable on transient cluster errors.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `func` | `Callable` | \u2014 | Function to call. |\n| `exceptions_dict` | `dict` | `DEFAULT_CLUSTER_RETRY_EXCEPTIONS` | Map of exception types to message substrings to match. |\n| `timeout` | `int` | `10` | Total retry timeout in seconds. |\n| `sleep_time` | `int` | `1` | Sleep between retries. |\n| `**kwargs` | | | Passed to `func`. |\n\n**Returns:** The return value of `func`.\n\n**Raises:** The last exception if timeout is reached.\n\n#### `get_condition_message`\n\n```python\ndef get_condition_message(\n self,\n condition_type: str,\n condition_status: str = \"\",\n) -> str\n```\n\nGet the message for a specific condition.\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `condition_type` | `str` | \u2014 | Condition type name. |\n| `condition_status` | `str` | `\"\"` | If set, only return the message when the condition status matches. |\n\n**Returns:** `str` \u2014 The condition message, or `\"\"` if not found or status doesn't match.\n\n#### `hash_resource_dict`\n\n```python\ndef hash_resource_dict(self, resource_dict: dict[Any, Any]) -> dict[Any, Any]\n```\n\nReplace sensitive fields (defined by `keys_to_hash`) with `\"*******\"` for logging.\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_dict` | `dict` | The resource dict to hash. |\n\n**Returns:** `dict` \u2014 A copy with sensitive values replaced.\n\n> **Tip:** Override the `keys_to_hash` property in subclasses to define which fields to mask.\n\n#### `keys_to_hash`\n\n```python\n@property\ndef keys_to_hash(self) -> list[str]\n```\n\nList of key paths to mask in logs. Uses `>` as separator, `[]` for list elements.\n\n**Returns:** `list[str]` \u2014 Default: `[]` (no hashing).\n\n```python\n# Example override in a Secret subclass:\n@property\ndef keys_to_hash(self):\n return [\"data\", \"stringData\"]\n```\n\n---\n\n### Inner Classes\n\n#### `Resource.Status`\n\nPre-defined status phase constants (inherited from `ResourceConstants`).\n\n| Constant | Value |\n|---|---|\n| `Status.SUCCEEDED` | `\"Succeeded\"` |\n| `Status.FAILED` | `\"Failed\"` |\n| `Status.DELETING` | `\"Deleting\"` |\n| `Status.DEPLOYED` | `\"Deployed\"` |\n| `Status.PENDING` | `\"Pending\"` |\n| `Status.COMPLETED` | `\"Completed\"` |\n| `Status.RUNNING` | `\"Running\"` |\n| `Status.READY` | `\"Ready\"` |\n| `Status.TERMINATING` | `\"Terminating\"` |\n| `Status.ERROR` | `\"Error\"` |\n| `Status.ACTIVE` | `\"Active\"` |\n| `Status.SCHEDULING` | `\"Scheduling\"` |\n| `Status.CRASH_LOOPBACK_OFF` | `\"CrashLoopBackOff\"` |\n| `Status.IMAGE_PULL_BACK_OFF` | `\"ImagePullBackOff\"` |\n\n```python\npod.wait_for_status(status=Pod.Status.RUNNING)\n```\n\n#### `Resource.Condition`\n\nPre-defined condition type and status constants.\n\n| Constant | Value |\n|---|---|\n| `Condition.READY` | `\"Ready\"` |\n| `Condition.AVAILABLE` | `\"Available\"` |\n| `Condition.DEGRADED` | `\"Degraded\"` |\n| `Condition.PROGRESSING` | `\"Progressing\"` |\n| `Condition.UPGRADEABLE` | `\"Upgradeable\"` |\n| `Condition.Status.TRUE` | `\"True\"` |\n| `Condition.Status.FALSE` | `\"False\"` |\n| `Condition.Status.UNKNOWN` | `\"Unknown\"` |\n\n```python\nns.wait_for_condition(\n condition=Resource.Condition.READY,\n status=Resource.Condition.Status.TRUE,\n)\n```\n\n#### `Resource.ApiGroup`\n\nPre-defined API group string constants. A selection of commonly used values:\n\n| Constant | Value |\n|---|---|\n| `ApiGroup.APPS` | `\"apps\"` |\n| `ApiGroup.BATCH` | `\"batch\"` |\n| `ApiGroup.NETWORKING_K8S_IO` | `\"networking.k8s.io\"` |\n| `ApiGroup.RBAC_AUTHORIZATION_K8S_IO` | `\"rbac.authorization.k8s.io\"` |\n| `ApiGroup.STORAGE_K8S_IO` | `\"storage.k8s.io\"` |\n| `ApiGroup.CONFIG_OPENSHIFT_IO` | `\"config.openshift.io\"` |\n| `ApiGroup.KUBEVIRT_IO` | `\"kubevirt.io\"` |\n| `ApiGroup.CDI_KUBEVIRT_IO` | `\"cdi.kubevirt.io\"` |\n| `ApiGroup.ROUTE_OPENSHIFT_IO` | `\"route.openshift.io\"` |\n| `ApiGroup.MACHINE_OPENSHIFT_IO` | `\"machine.openshift.io\"` |\n\n> **Note:** Over 100 API group constants are available. Use IDE auto-complete to discover all values.\n\n#### `Resource.ApiVersion`\n\n| Constant | Value |\n|---|---|\n| `ApiVersion.V1` | `\"v1\"` |\n| `ApiVersion.V1BETA1` | `\"v1beta1\"` |\n| `ApiVersion.V1ALPHA1` | `\"v1alpha1\"` |\n| `ApiVersion.V1ALPHA3` | `\"v1alpha3\"` |\n\n---\n\n## `NamespacedResource`\n\n```python\nfrom ocp_resources.resource import NamespacedResource\n```\n\nBase class for **namespace-scoped** resources (e.g., `Pod`, `Deployment`, `Service`, `ConfigMap`). Extends `Resource` with namespace awareness.\n\n### Constructor\n\n```python\nNamespacedResource(\n client=client,\n name=\"my-pod\",\n namespace=\"my-namespace\",\n)\n```\n\nAll parameters from [`Resource`](#constructor) are accepted, plus:\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `namespace` | `str \\| None` | `None` | Kubernetes namespace. Required unless `yaml_file` or `kind_dict` is provided. |\n\n**Raises:** `MissingRequiredArgumentError` if neither (`name` + `namespace`) nor `yaml_file` / `kind_dict` is provided.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n client=client,\n name=\"nginx\",\n namespace=\"default\",\n label={\"app\": \"web\"},\n)\n```\n\n### Overridden Methods\n\n#### `instance`\n\n```python\n@property\ndef instance(self) -> ResourceInstance\n```\n\nGet the live resource instance, scoped to `self.namespace`.\n\n**Returns:** `ResourceInstance`\n\n#### `to_dict`\n\n```python\ndef to_dict(self) -> None\n```\n\nPopulates `self.res` and sets `metadata.namespace`. If using `yaml_file` or `kind_dict`, reads the namespace from the YAML/dict.\n\n**Raises:** `MissingRequiredArgumentError` if namespace is still not set after processing.\n\n#### `get` (classmethod)\n\nBehaves like `Resource.get()`, but yields instances constructed with both `name` and `namespace`.\n\n```python\nfrom ocp_resources.pod import Pod\n\nfor pod in Pod.get(client=client, namespace=\"default\"):\n print(f\"{pod.namespace}/{pod.name}\")\n\n# With label selector\nfor pod in Pod.get(client=client, namespace=\"default\", label_selector=\"app=web\"):\n print(pod.name)\n```\n\n---\n\n## `ResourceEditor`\n\n```python\nfrom ocp_resources.resource import ResourceEditor\n```\n\nTemporarily patch resources and automatically restore original values. Designed for test scenarios.\n\n### Constructor\n\n```python\nResourceEditor(\n patches: dict[Any, Any],\n action: str = \"update\",\n user_backups: dict[Any, Any] | None = None,\n)\n```\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `patches` | `dict` | \u2014 | Map of `{Resource: patch_dict}`. |\n| `action` | `str` | `\"update\"` | `\"update\"` for merge-patch, `\"replace\"` for full replacement. |\n| `user_backups` | `dict \\| None` | `None` | Pre-computed backup dicts. If provided, skips automatic backup creation. |\n\n### Context Manager Usage\n\n```python\nfrom ocp_resources.resource import ResourceEditor\nfrom ocp_resources.namespace import Namespace\n\nns = Namespace(client=client, name=\"my-ns\", ensure_exists=True)\n\nwith ResourceEditor(patches={ns: {\"metadata\": {\"labels\": {\"temporary\": \"true\"}}}}) as editor:\n # Labels are patched\n assert ns.instance.metadata.labels.temporary == \"true\"\n# Labels are restored to original values\n```\n\n### Methods\n\n| Method | Signature | Description |\n|---|---|---|\n| `update` | `update(backup_resources: bool = False) -> None` | Apply patches. If `backup_resources=True`, back up original values first. |\n| `restore` | `restore() -> None` | Restore all backed-up values. |\n\n### Properties\n\n| Property | Type | Description |\n|---|---|---|\n| `backups` | `dict[Any, Any]` | Backed-up original values for each patched resource. |\n| `patches` | `dict[Any, Any]` | The patches dict from the constructor. |\n\n> **Warning:** The `DynamicClient` used to obtain the resource objects must have sufficient privileges to patch and replace resources.\n\nSee [Editing Resources Temporarily with ResourceEditor](editing-resources-temporarily.html) for detailed patterns.\n\n---\n\n## `ResourceList`\n\n```python\nfrom ocp_resources.resource import ResourceList\n```\n\nCreate and manage N copies of a cluster-scoped resource with indexed names.\n\n### Constructor\n\n```python\nResourceList(\n resource_class: type[Resource],\n num_resources: int,\n client: DynamicClient,\n **kwargs: Any,\n)\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_class` | `type[Resource]` | The resource class to instantiate. |\n| `num_resources` | `int` | Number of resource copies to create. |\n| `client` | `DynamicClient` | Kubernetes client. |\n| `**kwargs` | | Passed to each resource constructor. Must include `name` (used as base name). |\n\nResources are named `{name}-1`, `{name}-2`, ..., `{name}-N`.\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import ResourceList\n\nnamespaces = ResourceList(\n resource_class=Namespace,\n num_resources=3,\n client=client,\n name=\"test-ns\",\n)\n\nwith namespaces:\n # Creates test-ns-1, test-ns-2, test-ns-3\n for ns in namespaces:\n print(ns.name)\n# All three namespaces are deleted\n```\n\n### Methods (inherited from `BaseResourceList`)\n\n| Method | Signature | Returns | Description |\n|---|---|---|---|\n| `deploy` | `deploy(wait=False)` | `list[Resource]` | Deploy all resources. |\n| `clean_up` | `clean_up(wait=True)` | `bool` | Delete all resources in reverse order. |\n| `__len__` | `__len__()` | `int` | Number of resources. |\n| `__getitem__` | `__getitem__(index)` | `Resource` | Access by index. |\n| `__iter__` | `__iter__()` | `Generator` | Iterate over resources. |\n\n---\n\n## `NamespacedResourceList`\n\n```python\nfrom ocp_resources.resource import NamespacedResourceList\n```\n\nCreate one instance of a namespaced resource in each namespace from a `ResourceList`.\n\n### Constructor\n\n```python\nNamespacedResourceList(\n resource_class: type[NamespacedResource],\n namespaces: ResourceList,\n client: DynamicClient,\n **kwargs: Any,\n)\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_class` | `type[NamespacedResource]` | The namespaced resource class (e.g., `Pod`). |\n| `namespaces` | `ResourceList` | A `ResourceList` of `Namespace` resources. |\n| `client` | `DynamicClient` | Kubernetes client. |\n| `**kwargs` | | Passed to each resource constructor. Must include `name`. |\n\n**Raises:** `TypeError` if any resource in `namespaces` is not a `Namespace`.\n\n```python\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import ResourceList, NamespacedResourceList\n\nnamespaces = ResourceList(resource_class=Namespace, num_resources=2, client=client, name=\"test-ns\")\n\npods = NamespacedResourceList(\n resource_class=Pod,\n namespaces=namespaces,\n client=client,\n name=\"nginx\",\n)\n\nwith namespaces:\n with pods:\n # Creates nginx in test-ns-1 and test-ns-2\n for pod in pods:\n print(f\"{pod.namespace}/{pod.name}\")\n```\n\nSee [Managing Bulk Resources with ResourceList](managing-resource-lists.html) for full usage patterns.\n\n---\n\n## `KubeAPIVersion`\n\n```python\nfrom ocp_resources.resource import KubeAPIVersion\n```\n\nImplements [Kubernetes API versioning](https://kubernetes.io/docs/concepts/overview/kubernetes-api/#api-versioning) with comparison operators. Extends `packaging.version.Version`.\n\n### Constructor\n\n```python\nKubeAPIVersion(vstring: str)\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `vstring` | `str` | Version string (e.g., `\"v1\"`, `\"v1beta1\"`, `\"v1alpha2\"`). |\n\n**Raises:** `ValueError` if the version string does not conform to Kubernetes versioning.\n\n### Ordering\n\nVersions are ordered: `v1alpha1 < v1alpha2 < v1beta1 < v1beta2 < v1 < v2`.\n\n```python\nassert KubeAPIVersion(\"v1\") > KubeAPIVersion(\"v1beta1\")\nassert KubeAPIVersion(\"v1beta1\") > KubeAPIVersion(\"v1alpha1\")\nassert KubeAPIVersion(\"v1\") == KubeAPIVersion(\"v1\")\n```\n\n---\n\n## Exceptions\n\nAll exceptions are importable from `ocp_resources.exceptions`.\n\n```python\nfrom ocp_resources.exceptions import (\n MissingRequiredArgumentError,\n ResourceTeardownError,\n ValidationError,\n ConditionError,\n)\n```\n\n| Exception | Raised By | Description |\n|---|---|---|\n| `MissingRequiredArgumentError` | `Resource.__init__`, `NamespacedResource.__init__`, `NamespacedResource._base_body` | Required parameters (`name`, `namespace`) not provided. |\n| `ResourceTeardownError` | `Resource.__exit__` | `clean_up()` returned `False` during context manager exit. |\n| `ValidationError` | `validate()`, `validate_dict()`, `create()`, `update_replace()` | Resource dict fails OpenAPI schema validation. Has `message`, `path`, and `schema_error` attributes. |\n| `ConditionError` | `wait_for_condition()` | A `stop_condition` was detected during condition waiting. |\n| `MissingResourceResError` | `Resource._base_body` | Deprecated. `self.res` is empty after `_base_body()`. |\n\n---\n\n## Timeout Constants\n\nAvailable from `ocp_resources.utils.constants`:\n\n```python\nfrom ocp_resources.utils.constants import (\n TIMEOUT_1SEC, # 1\n TIMEOUT_5SEC, # 5\n TIMEOUT_10SEC, # 10\n TIMEOUT_30SEC, # 30\n TIMEOUT_1MINUTE, # 60\n TIMEOUT_2MINUTES, # 120\n TIMEOUT_4MINUTES, # 240\n TIMEOUT_10MINUTES, # 600\n)\n```\n\n---\n\n## Module-Level Functions\n\n### `replace_key_with_hashed_value`\n\n```python\nfrom ocp_resources.resource import replace_key_with_hashed_value\n\nreplace_key_with_hashed_value(resource_dict: dict, key_name: str) -> dict\n```\n\nRecursively replace the value at a key path with `\"*******\"`. Supports dict paths (`a>b>c`) and list paths (`a>b[]>c`).\n\n| Parameter | Type | Description |\n|---|---|---|\n| `resource_dict` | `dict` | The nested dictionary. |\n| `key_name` | `str` | Key path using `>` as separator, `[]` for list elements. |\n\n**Returns:** `dict` \u2014 A deep copy with the value replaced.\n\n```python\ndata = {\"spec\": {\"secret\": {\"token\": \"sensitive\"}}}\nhashed = replace_key_with_hashed_value(data, \"spec>secret>token\")\n# {\"spec\": {\"secret\": {\"token\": \"*******\"}}}\n\n# With list path\ndata = {\"spec\": {\"containers\": [{\"env\": [{\"value\": \"secret\"}]}]}}\nhashed = replace_key_with_hashed_value(data, \"spec>containers[]>env[]>value\")\n```\n\n## Related Pages\n\n- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Querying and Watching Resources](querying-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Environment Variables and Configuration](environment-variables.html)\n"}, {"slug": "environment-variables", "title": "Environment Variables and Configuration", "content": "# Environment Variables and Configuration\n\nThis page documents all environment variables recognized by `openshift-python-wrapper` at runtime. These variables control logging behavior, resource lifecycle operations, proxy configuration, cluster connectivity, and sensitive data hashing.\n\n## Quick Reference\n\n| Environment Variable | Purpose | Default |\n|---|---|---|\n| `KUBECONFIG` | Path to kubeconfig file | `~/.kube/config` |\n| `HTTPS_PROXY` | HTTPS proxy URL for cluster connections | _(unset)_ |\n| `HTTP_PROXY` | HTTP proxy URL for cluster connections | _(unset)_ |\n| `OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL` | Logging verbosity level | `INFO` |\n| `OPENSHIFT_PYTHON_WRAPPER_LOG_FILE` | Path to log output file | `\"\"` (stdout) |\n| `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` | Enable/disable hashing sensitive data in logs | `true` |\n| `REUSE_IF_RESOURCE_EXISTS` | Skip resource creation if already exists | _(unset)_ |\n| `SKIP_RESOURCE_TEARDOWN` | Skip resource deletion during teardown | _(unset)_ |\n\n---\n\n## Cluster Connection\n\n### `KUBECONFIG`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` |\n| **Default** | `~/.kube/config` |\n| **Used by** | `get_client()` |\n| **Source** | `ocp_resources/resource.py` |\n\nPath to the kubeconfig file used when creating a Kubernetes `DynamicClient`. Read when no explicit `config_file`, `config_dict`, `host`/`token`, or `username`/`password` arguments are passed to `get_client()`.\n\n> **Note:** The standard `kubernetes` Python client reads `KUBECONFIG` at import time. If you set this variable in code (after import), `openshift-python-wrapper` handles it by explicitly passing the value to the client constructor. See [Connecting to Clusters](connecting-to-clusters.html) for all connection options.\n\n```bash\nexport KUBECONFIG=/path/to/my/kubeconfig\n```\n\n```python\nfrom ocp_resources.resource import get_client\n\n# Automatically uses $KUBECONFIG, or falls back to ~/.kube/config\nclient = get_client()\n```\n\n---\n\n### `HTTPS_PROXY` / `HTTP_PROXY`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` |\n| **Default** | _(unset \u2014 no proxy)_ |\n| **Used by** | `get_client()` |\n| **Source** | `ocp_resources/resource.py` |\n\nSets the proxy on the Kubernetes client configuration when no proxy is already configured on the `client_configuration` object. `HTTPS_PROXY` takes precedence over `HTTP_PROXY` when both are set.\n\n```bash\nexport HTTPS_PROXY=http://proxy.example.com:8080\n```\n\n```python\nfrom ocp_resources.resource import get_client\n\n# Proxy is automatically applied from environment\nclient = get_client()\n```\n\n> **Tip:** If you pass a `client_configuration` object that already has a `.proxy` set, the environment variables are ignored.\n\n**Precedence order:**\n\n1. Explicit `client_configuration.proxy` (if already set)\n2. `HTTPS_PROXY` environment variable\n3. `HTTP_PROXY` environment variable\n\n---\n\n## Logging\n\n### `OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` |\n| **Default** | `INFO` |\n| **Valid values** | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |\n| **Used by** | `Resource._set_logger()` |\n| **Source** | `ocp_resources/resource.py` |\n\nControls the log level for each resource instance's logger. The logger is created per-resource using the `simple_logger` library.\n\n```bash\nexport OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG\n```\n\n```python\nimport os\n\nos.environ[\"OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL\"] = \"DEBUG\"\n\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\npod = Pod(name=\"my-pod\", namespace=\"default\", client=client)\n# Logger on this Pod instance now uses DEBUG level\n```\n\n---\n\n### `OPENSHIFT_PYTHON_WRAPPER_LOG_FILE`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` |\n| **Default** | `\"\"` (empty string \u2014 logs to stdout) |\n| **Used by** | `Resource._set_logger()` |\n| **Source** | `ocp_resources/resource.py` |\n\nRedirects resource log output to a file. When unset or empty, logs are written to stdout.\n\n```bash\nexport OPENSHIFT_PYTHON_WRAPPER_LOG_FILE=/var/log/ocp-wrapper.log\n```\n\n```python\nimport os\n\nos.environ[\"OPENSHIFT_PYTHON_WRAPPER_LOG_FILE\"] = \"/tmp/ocp-wrapper.log\"\n\nfrom ocp_resources.namespace import Namespace\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\nns = Namespace(name=\"test-ns\", client=client)\n# All log output for this resource is written to /tmp/ocp-wrapper.log\n```\n\n---\n\n## Sensitive Data Hashing\n\n### `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` |\n| **Default** | `\"true\"` |\n| **Valid values** | `\"true\"`, `\"false\"` |\n| **Used by** | `Resource.hash_resource_dict()` |\n| **Source** | `ocp_resources/resource.py` |\n\nControls whether sensitive fields in resource dictionaries are replaced with `\"*******\"` when logged. When set to `\"false\"`, sensitive data is logged in cleartext.\n\nHashing is applied to fields declared in each resource class's `keys_to_hash` property. The following built-in resources define sensitive keys:\n\n| Resource Class | Hashed Fields |\n|---|---|\n| `Secret` | `data`, `stringData` |\n| `ConfigMap` | `data`, `binaryData` |\n| `SealedSecret` | `spec>data`, `spec>encryptedData` |\n| `VirtualMachine` | `spec>template>spec>volumes[]>cloudInitNoCloud>userData` |\n\n> **Warning:** Setting this to `\"false\"` causes sensitive data (e.g., secrets, tokens) to appear in log output. Use only for debugging in secure environments.\n\n```bash\n# Disable hashing to see raw values in logs (debug only)\nexport OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA=false\n```\n\n```python\nimport os\n\nos.environ[\"OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA\"] = \"false\"\n\nfrom ocp_resources.secret import Secret\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\nsecret = Secret(name=\"my-secret\", namespace=\"default\", client=client)\n# Logs will now show raw secret data instead of \"*******\"\n```\n\n> **Note:** Hashing also depends on the `hash_log_data` constructor parameter on each resource instance. Both must be enabled for hashing to occur. The `hash_log_data` parameter defaults to `True`. See [Resource and NamespacedResource API](resource-api.html) for constructor details.\n\n**Interaction with `hash_log_data` parameter:**\n\n| `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` | `hash_log_data` param | Result |\n|---|---|---|\n| `\"true\"` (default) | `True` (default) | Sensitive fields hashed |\n| `\"true\"` | `False` | Sensitive fields **not** hashed |\n| `\"false\"` | `True` | Sensitive fields **not** hashed |\n| `\"false\"` | `False` | Sensitive fields **not** hashed |\n\n---\n\n## Resource Reuse (Skip Creation)\n\n### `REUSE_IF_RESOURCE_EXISTS`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` (YAML dict syntax) |\n| **Default** | _(unset \u2014 no resources skipped)_ |\n| **Used by** | `Resource.deploy()` |\n| **Source** | `ocp_resources/resource.py` |\n\nWhen set, `deploy()` checks whether the target resource already exists on the cluster. If a match is found, the existing resource is returned without creating a new one. This is intended for debugging and iterative development workflows.\n\n> **Warning:** Spaces are significant in the value syntax. The value is parsed as YAML.\n\n**Value format:**\n\n```\n{: {: }}\n```\n\n**Matching rules:**\n\n| Pattern | Behavior |\n|---|---|\n| `{Pod: {}}` | Skip creation for **all** Pods (match by kind only) |\n| `{Pod: {my-pod:}}` | Skip creation for Pod named `my-pod` in **any** namespace |\n| `{Pod: {my-pod: my-ns}}` | Skip creation for Pod named `my-pod` in namespace `my-ns` only |\n| `{Kind1: {}, Kind2: {name: ns}}` | Multiple resource patterns combined |\n\n**Examples:**\n\n```bash\n# Skip all Pod creation if the pod already exists\nexport REUSE_IF_RESOURCE_EXISTS=\"{Pod: {}}\"\n\n# Skip specific pod in specific namespace\nexport REUSE_IF_RESOURCE_EXISTS=\"{Pod: {my-pod: my-namespace}}\"\n\n# Skip namespace and pod creation\nexport REUSE_IF_RESOURCE_EXISTS=\"{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}\"\n```\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\n# If REUSE_IF_RESOURCE_EXISTS is set and a matching Pod exists,\n# deploy() returns the existing resource without calling create()\npod = Pod(\n name=\"my-pod\",\n namespace=\"my-namespace\",\n client=client,\n containers=[{\"name\": \"test\", \"image\": \"nginx\"}],\n)\npod.deploy() # Skips creation if resource matches the env var pattern\n```\n\n> **Note:** The resource must actually exist on the cluster for the skip to take effect. If the resource does not exist, `deploy()` proceeds with normal creation.\n\n---\n\n## Skip Teardown\n\n### `SKIP_RESOURCE_TEARDOWN`\n\n| Property | Value |\n|---|---|\n| **Type** | `str` (YAML dict syntax) |\n| **Default** | _(unset \u2014 no resources skipped)_ |\n| **Used by** | `Resource.clean_up()` |\n| **Source** | `ocp_resources/resource.py` |\n\nWhen set, `clean_up()` skips deletion for matching resources and returns `True` without calling `delete()`. This is intended for debugging \u2014 preserving resources on the cluster after tests finish.\n\n> **Warning:** Spaces are significant in the value syntax. The value is parsed as YAML.\n\n**Value format:**\n\nUses the same YAML dict syntax as [`REUSE_IF_RESOURCE_EXISTS`](#reuse_if_resource_exists).\n\n**Matching rules:**\n\n| Pattern | Behavior |\n|---|---|\n| `{Pod: {}}` | Skip teardown for **all** Pods |\n| `{Pod: {my-pod:}}` | Skip teardown for Pod named `my-pod` in **any** namespace |\n| `{Pod: {my-pod: my-ns}}` | Skip teardown for Pod named `my-pod` in namespace `my-ns` only |\n| `{Kind1: {}, Kind2: {name: ns}}` | Multiple resource patterns combined |\n\n**Examples:**\n\n```bash\n# Keep all Namespaces after test run\nexport SKIP_RESOURCE_TEARDOWN=\"{Namespace: {}}\"\n\n# Keep specific resources\nexport SKIP_RESOURCE_TEARDOWN=\"{Namespace: {test-ns:}, Pod: {debug-pod: test-ns}}\"\n```\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\npod = Pod(\n name=\"debug-pod\",\n namespace=\"test-ns\",\n client=client,\n containers=[{\"name\": \"test\", \"image\": \"nginx\"}],\n)\npod.deploy()\n\n# If SKIP_RESOURCE_TEARDOWN is set with a matching pattern,\n# clean_up() returns True without deleting the resource\npod.clean_up() # Resource remains on the cluster\n```\n\n> **Tip:** Use `REUSE_IF_RESOURCE_EXISTS` and `SKIP_RESOURCE_TEARDOWN` together for a fast debug loop: skip creation if a resource already exists, and skip teardown so it persists between runs.\n\n```bash\nexport REUSE_IF_RESOURCE_EXISTS=\"{Pod: {debug-pod: test-ns}}\"\nexport SKIP_RESOURCE_TEARDOWN=\"{Pod: {debug-pod: test-ns}}\"\n```\n\n---\n\n## Combined Usage Patterns\n\n### Debug iteration loop\n\nSet both skip variables to avoid re-creating and tearing down resources between test runs:\n\n```bash\nexport REUSE_IF_RESOURCE_EXISTS=\"{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}\"\nexport SKIP_RESOURCE_TEARDOWN=\"{Namespace: {test-ns:}, Pod: {my-pod: test-ns}}\"\n```\n\n### Full debug logging with unhashed data\n\n```bash\nexport OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG\nexport OPENSHIFT_PYTHON_WRAPPER_LOG_FILE=/tmp/debug.log\nexport OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA=false\n```\n\n> **Warning:** This combination logs sensitive data in plaintext. Do not use in production or shared environments.\n\n### Custom kubeconfig with proxy\n\n```bash\nexport KUBECONFIG=/path/to/custom/kubeconfig\nexport HTTPS_PROXY=http://corporate-proxy.example.com:3128\n```\n\n---\n\n## Programmatic Configuration Alternatives\n\nSeveral behaviors controlled by environment variables can also be set programmatically via constructor parameters or function arguments. See [Resource and NamespacedResource API](resource-api.html) for full constructor parameter documentation.\n\n| Environment Variable | Programmatic Alternative |\n|---|---|\n| `KUBECONFIG` | `get_client(config_file=\"/path/to/kubeconfig\")` |\n| `HTTPS_PROXY` / `HTTP_PROXY` | `client_configuration.proxy = \"http://proxy:port\"` |\n| `OPENSHIFT_PYTHON_WRAPPER_HASH_LOG_DATA` | `Resource(hash_log_data=False, ...)` constructor parameter |\n\n> **Note:** `OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL`, `OPENSHIFT_PYTHON_WRAPPER_LOG_FILE`, `REUSE_IF_RESOURCE_EXISTS`, and `SKIP_RESOURCE_TEARDOWN` can **only** be configured via environment variables.\n\n## Related Pages\n\n- [Connecting to Clusters](connecting-to-clusters.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Resource and NamespacedResource API](resource-api.html)\n- [Common Resource Patterns](common-patterns.html)\n- [Error Handling and Exception Reference](error-handling.html)\n"}, {"slug": "resource-class-hierarchy", "title": "Understanding the Resource Class Hierarchy", "content": "# Understanding the Resource Class Hierarchy\n\nEvery Kubernetes and OpenShift object you interact with through openshift-python-wrapper \u2014 whether it's a Pod, a Namespace, or a VirtualMachine \u2014 is a Python class that inherits from a small set of base classes. Understanding how these classes relate to each other helps you know **what capabilities any resource has**, **why some resources require a `namespace` argument and others don't**, and **how new resource types get added to the library**.\n\nThis page explains the architecture so you can use existing resources effectively and extend the library when you need to work with a new custom resource.\n\n---\n\n## The Big Picture\n\nThe class hierarchy has three conceptual layers:\n\n| Layer | Classes | Purpose |\n|---|---|---|\n| **Constants** | `ResourceConstants` | Provides shared status strings, condition names, and type constants used across all resources |\n| **Base classes** | `Resource`, `NamespacedResource` | Define all CRUD operations, lifecycle management, waiting, validation, and API discovery logic |\n| **Concrete resource classes** | `Pod`, `Namespace`, `Deployment`, `VirtualMachine`, and 200+ others | Represent specific Kubernetes/OpenShift resource kinds, adding kind-specific parameters and behavior |\n\nThe inheritance flows like this:\n\n```\nResourceConstants\n \u2514\u2500\u2500 Resource \u2190 cluster-scoped resources (Node, Namespace, StorageClass, ClusterRole, \u2026)\n \u2514\u2500\u2500 NamespacedResource \u2190 namespace-scoped resources (Pod, Deployment, ConfigMap, Secret, Route, \u2026)\n```\n\nEvery concrete resource class inherits from either `Resource` (for cluster-scoped resources) or `NamespacedResource` (for namespace-scoped resources). This single design decision controls whether the class requires a `namespace` argument and how it builds API requests.\n\n---\n\n## Key Concepts\n\n### ResourceConstants: Shared Vocabulary\n\nAt the root of the hierarchy sits `ResourceConstants`, which defines inner classes for common values:\n\n- **`Status`** \u2014 Strings like `RUNNING`, `SUCCEEDED`, `FAILED`, `PENDING`, `ACTIVE`\n- **`Condition`** \u2014 Condition types like `READY`, `AVAILABLE`, `DEGRADED` and their status values (`TRUE`, `FALSE`, `UNKNOWN`)\n- **`Type`** \u2014 Service types like `ClusterIP`, `NodePort`, `LoadBalancer`\n\nBecause `Resource` inherits from `ResourceConstants`, every resource class in the library can reference these constants:\n\n```python\nfrom ocp_resources.namespace import Namespace\n\nns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120)\n```\n\nConcrete classes can extend these constants with kind-specific values. For example, `VirtualMachine` adds statuses like `MIGRATING`, `STOPPED`, and `PROVISIONING` to the base `Status` class.\n\n### Resource: The Foundation for Cluster-Scoped Resources\n\n`Resource` is the main base class. It provides everything needed to manage a Kubernetes resource:\n\n| Capability | Methods / Properties |\n|---|---|\n| **CRUD operations** | `create()`, `delete()`, `update()`, `update_replace()` |\n| **Lifecycle management** | `deploy()`, `clean_up()`, context manager (`with` statement) |\n| **Querying** | `exists`, `instance`, `status`, `labels` |\n| **Waiting** | `wait()`, `wait_deleted()`, `wait_for_status()`, `wait_for_condition()` |\n| **Listing** | `get()` class method \u2014 yields resource objects matching filters |\n| **Validation** | `validate()`, `validate_dict()` |\n| **Serialization** | `to_dict()`, `to_yaml()` |\n| **API discovery** | Automatic `api_version` resolution from the cluster when only `api_group` is set |\n\nResources that exist at the cluster level \u2014 not inside any namespace \u2014 inherit directly from `Resource`. Examples include `Namespace`, `Node`, `StorageClass`, `ClusterRole`, and `CustomResourceDefinition`.\n\n```python\nfrom ocp_resources.namespace import Namespace\n\n# No namespace argument needed \u2014 Namespace is cluster-scoped\nns = Namespace(client=client, name=\"my-namespace\")\n```\n\n#### Automatic Kind Detection\n\nYou never set the `kind` field manually. The `kind` property is a class-level property that automatically derives the Kubernetes kind name from the **class name** using Python's method resolution order (MRO). When you define a class named `StorageClass`, its `kind` is automatically `\"StorageClass\"`.\n\n#### API Version Discovery\n\nResources can specify their API identity in two ways:\n\n1. **`api_version`** \u2014 A fixed version string (e.g., `\"v1\"`), used for core Kubernetes resources\n2. **`api_group`** \u2014 An API group string (e.g., `\"apps\"`, `\"kubevirt.io\"`), where the full `apiVersion` is discovered dynamically from the cluster\n\nWhen only `api_group` is set, the library queries the cluster at runtime to find the latest supported version for that resource kind. This means resource classes automatically work across cluster versions without code changes.\n\n```python\nclass Namespace(Resource):\n # Core resource \u2014 fixed version, no group\n api_version: str = Resource.ApiVersion.V1\n\n\nclass ClusterRole(Resource):\n # Grouped resource \u2014 version discovered from cluster\n api_group = Resource.ApiGroup.RBAC_AUTHORIZATION_K8S_IO\n```\n\n> **Note:** The `Resource.ApiGroup` and `Resource.ApiVersion` inner classes provide predefined constants for all known API groups and versions. Using these constants avoids typos and makes your code self-documenting.\n\n### NamespacedResource: Adding Namespace Awareness\n\n`NamespacedResource` extends `Resource` with one critical addition: **namespace handling**. It requires a `namespace` argument during construction and injects the namespace into all API calls.\n\nThe differences from `Resource` are focused but important:\n\n| Behavior | `Resource` | `NamespacedResource` |\n|---|---|---|\n| `namespace` required? | No | Yes (unless using `yaml_file` or `kind_dict`) |\n| `to_dict()` output | No namespace in metadata | Adds `metadata.namespace` |\n| `get()` yields | Objects with `name` only | Objects with both `name` and `namespace` |\n| `instance` property | Fetches by name only | Fetches by name and namespace |\n\n```python\nfrom ocp_resources.pod import Pod\n\n# Namespace is required for namespaced resources\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\", containers=[{\"name\": \"app\", \"image\": \"nginx\"}])\n```\n\n### Concrete Resource Classes: Where Specifics Live\n\nConcrete classes add three things on top of the base classes:\n\n1. **API identity** \u2014 Setting `api_group` or `api_version` to identify which Kubernetes API to call\n2. **Constructor parameters** \u2014 Typed arguments for the resource's spec fields (like `containers` for Pod, `replicas` for Deployment)\n3. **Custom `to_dict()` method** \u2014 Builds the Kubernetes resource dictionary from constructor arguments\n\nHere is how a typical generated class is structured:\n\n```python\nclass Deployment(NamespacedResource):\n # 1. API identity\n api_group: str = NamespacedResource.ApiGroup.APPS\n\n def __init__(self, replicas=None, selector=None, template=None, **kwargs):\n # 2. Pass common args to base class, store kind-specific args\n super().__init__(**kwargs)\n self.replicas = replicas\n self.selector = selector\n self.template = template\n\n def to_dict(self):\n # 3. Build the resource dictionary\n super().to_dict()\n if not self.kind_dict and not self.yaml_file:\n self.res[\"spec\"] = {}\n _spec = self.res[\"spec\"]\n _spec[\"selector\"] = self.selector\n _spec[\"template\"] = self.template\n if self.replicas is not None:\n _spec[\"replicas\"] = self.replicas\n```\n\n> **Tip:** You can always bypass the typed constructor entirely by passing `yaml_file` or `kind_dict` to any resource class. When you do, the `to_dict()` logic is skipped and the resource is created from your raw definition instead.\n\n#### Adding Kind-Specific Behavior\n\nMany concrete classes go beyond what the generator produces by adding custom methods and properties. For example:\n\n- **Pod** adds `execute()` for running commands, `log()` for reading logs, and a `node` property\n- **Deployment** adds `scale_replicas()` and `wait_for_replicas()`\n- **Secret** overrides `keys_to_hash` to ensure sensitive data is masked in logs\n\nThese additions are preserved across regeneration because the class generator recognizes the `# End of generated code` marker and keeps any code written below it.\n\n### How Generated Classes Are Created\n\nMost concrete resource classes in the library are **code-generated** from the cluster's OpenAPI schema using the `class-generator` tool. The generator:\n\n1. Reads the resource definition from the cluster's OpenAPI schema\n2. Determines whether the resource is namespaced (\u2192 `NamespacedResource`) or cluster-scoped (\u2192 `Resource`)\n3. Extracts spec fields with their types and descriptions\n4. Renders a Python class from a Jinja2 template\n5. Preserves any hand-written code below the `# End of generated code` marker\n\nThis means the hierarchy is not just an architectural choice \u2014 it is **enforced by the code generation pipeline**. Every generated class correctly inherits from the appropriate base class based on the resource's actual scope in Kubernetes.\n\nSee [Generating New Resource Classes with class-generator](generating-resource-classes.html) for details on generating classes for new resource types.\n\n---\n\n## How It Affects You\n\nUnderstanding the hierarchy helps you in several practical ways:\n\n### Knowing What Methods Are Available\n\nEvery resource \u2014 regardless of kind \u2014 inherits the full set of CRUD, waiting, and lifecycle methods from `Resource`. You don't need to check whether a particular resource supports `wait_for_condition()` or context managers; they all do.\n\n```python\n# Works for any resource type\nwith SomeResource(client=client, name=\"example\", **specific_args) as res:\n res.wait_for_condition(condition=\"Ready\", status=\"True\")\n```\n\nSee [Resource and NamespacedResource API](resource-api.html) for the complete method reference.\n\n### Understanding Constructor Requirements\n\nThe base class determines what arguments are mandatory:\n\n| If you're using... | Required arguments |\n|---|---|\n| `Resource` subclass | `client`, `name` |\n| `NamespacedResource` subclass | `client`, `name`, `namespace` |\n| Any class with `yaml_file` | `client`, `yaml_file` |\n| Any class with `kind_dict` | `client`, `kind_dict` |\n\nSee [Creating and Managing Resources](creating-and-managing-resources.html) for full examples of all creation methods.\n\n### Using Status and Condition Constants\n\nThe `Status` and `Condition` constants inherited from `ResourceConstants` are available on every resource class. Concrete classes may extend them:\n\n```python\nfrom ocp_resources.virtual_machine import VirtualMachine\n\n# Base constants work on all resources\nvm.wait_for_status(status=VirtualMachine.Status.RUNNING)\n\n# Kind-specific constants are added by the concrete class\nvm.wait_for_status(status=VirtualMachine.Status.STOPPED)\n```\n\n### Extending the Library\n\nIf you need to work with a CRD that isn't included in the library, you have two options:\n\n1. **Use the class generator** to scaffold a new class automatically \u2014 see [Generating New Resource Classes with class-generator](generating-resource-classes.html)\n2. **Write a class manually** by inheriting from `Resource` or `NamespacedResource` and setting `api_group` or `api_version`\n\nA minimal hand-written resource class looks like this:\n\n```python\nfrom ocp_resources.resource import NamespacedResource\n\n\nclass MyCustomResource(NamespacedResource):\n api_group = \"example.com\"\n # That's it \u2014 you get full CRUD, waiting, and lifecycle support\n```\n\n> **Warning:** Your class name must match the Kubernetes `kind` exactly (in PascalCase). The `kind` property is derived from the class name automatically \u2014 a class named `MyCustomResource` will have `kind = \"MyCustomResource\"`.\n\n---\n\n## Related Pages\n\n- [Resource and NamespacedResource API](resource-api.html) \u2014 Complete method and property reference for the base classes\n- [Creating and Managing Resources](creating-and-managing-resources.html) \u2014 Practical guide to creating, updating, and deleting resources\n- [Generating New Resource Classes with class-generator](generating-resource-classes.html) \u2014 Generate Python classes for any CRD\n- [Querying and Watching Resources](querying-resources.html) \u2014 Use the `get()` class method and watchers to list and observe resources\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html) \u2014 Use the waiting methods inherited from the base classes\n- [Validating Resources Against OpenAPI Schemas](validating-resources.html) \u2014 Validate resources using the built-in schema validation\n- [Environment Variables and Configuration](environment-variables.html) \u2014 Configure logging, resource reuse, and teardown behavior\n\n## Related Pages\n\n- [Resource and NamespacedResource API](resource-api.html)\n- [Generating New Resource Classes with class-generator](generating-resource-classes.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Schema Validation and Code Generation Architecture](schema-validation-internals.html)\n- [Validating Resources Against OpenAPI Schemas](validating-resources.html)\n"}, {"slug": "schema-validation-internals", "title": "Schema Validation and Code Generation Architecture", "content": "# Schema Validation and Code Generation Architecture\n\nThe openshift-python-wrapper uses OpenAPI schemas as the single source of truth for two critical capabilities: **validating resource definitions** before they reach the cluster, and **generating Python wrapper classes** for any Kubernetes or OpenShift resource type. Understanding this architecture helps you diagnose validation errors, generate classes for custom resources, and keep your schemas up to date as clusters evolve.\n\n## The Big Picture\n\nOpenAPI schemas flow through the system in two directions: they are fetched from a live cluster and stored locally, then consumed by the validation engine at runtime and by the code generator at development time.\n\n| Component | Location | Purpose |\n|---|---|---|\n| **Schema Fetcher** | `class_generator/core/schema.py` | Connects to a cluster, downloads OpenAPI v3 schemas, and writes them to disk |\n| **Resource Mappings** | `class_generator/schema/__resources-mappings.json.gz` | Compressed archive mapping each resource kind (lowercase) to its schema(s) |\n| **Definitions File** | `class_generator/schema/_definitions.json` | JSON file containing detailed schema definitions with `$ref` targets for nested types |\n| **Schema Validator** | `ocp_resources/utils/schema_validator.py` | Runtime engine that loads schemas, resolves `$ref` references, and validates resource dicts |\n| **Code Generator** | `class_generator/core/generator.py` | Reads schemas and generates Python class files from a Jinja2 template |\n| **Explain Parser** | `class_generator/parsers/explain_parser.py` | Parses the resource mapping to extract fields, types, and group-version-kind metadata |\n| **Cluster Version Tracker** | `class_generator/schema/__cluster_version__.txt` | Tracks the cluster version that last produced the schema, controlling update strategy |\n\n### Data Flow: Schema Fetch and Storage\n\n1. **Detect client binary** \u2014 The system finds `oc` or falls back to `kubectl` via `get_client_binary()`.\n2. **Check cluster version** \u2014 `check_and_update_cluster_version()` compares the connected cluster's version against the stored version in `__cluster_version__.txt`.\n3. **Determine update strategy** \u2014 If the cluster is the same version or newer, all schemas are fetched and existing entries are updated. If older, only missing resources are fetched and existing schemas are preserved.\n4. **Fetch OpenAPI v3 index** \u2014 `GET /openapi/v3` returns an index of all API group paths (e.g., `api/v1`, `apis/apps/v1`).\n5. **Fetch schemas in parallel** \u2014 `fetch_all_api_schemas()` downloads schemas from each API path using a thread pool (up to 10 workers).\n6. **Build namespacing dictionary** \u2014 `build_namespacing_dict()` queries `api-resources` to determine which kinds are namespaced vs. cluster-scoped.\n7. **Process definitions** \u2014 `process_schema_definitions()` extracts `x-kubernetes-group-version-kind` metadata, builds schema entries, and merges them into the resource mappings. Existing resources are **never deleted** \u2014 only added or updated.\n8. **Supplement with `oc explain`** \u2014 Missing field descriptions, required-field markers, and `$ref` definitions are filled in by running `oc explain` commands in parallel.\n9. **Write to disk** \u2014 The resource mappings are saved as a gzip-compressed JSON archive (`__resources-mappings.json.gz`), and definitions are written to `_definitions.json`.\n\n### Data Flow: Validation at Runtime\n\n1. **Load on first use** \u2014 `SchemaValidator.load_mappings_data()` decompresses and loads the mappings archive and definitions file into class-level caches.\n2. **Look up by kind** \u2014 `SchemaValidator.load_schema(kind, api_group)` finds the schema for a resource kind (case-insensitive). When multiple API groups define the same kind (e.g., `Ingress` in `networking.k8s.io` and `config.openshift.io`), the `api_group` parameter disambiguates.\n3. **Resolve `$ref` references** \u2014 The validator recursively resolves all `$ref` pointers against the definitions data, producing a self-contained schema.\n4. **Cache resolved schemas** \u2014 Resolved schemas are stored in `_schema_cache` keyed by `api_group:kind`, so subsequent validations are fast.\n5. **Validate with jsonschema** \u2014 `jsonschema.validate()` checks the resource dictionary against the resolved schema.\n6. **Format errors** \u2014 If validation fails, `format_validation_error()` produces a human-readable message including the field path, error details, and schema context.\n\n### Data Flow: Code Generation\n\n1. **Read resource mappings** \u2014 `parse_explain()` loads the mapping file and finds all schema entries for the requested kind.\n2. **Select latest API version** \u2014 When multiple versions exist within an API group, the latest version is selected using a priority ranking (`v2 > v1 > v1beta2 > v1beta1 > v1alpha2 > v1alpha1`).\n3. **Extract fields and types** \u2014 The `type_parser` module converts OpenAPI types to Python type annotations and builds parameter dictionaries for the class constructor.\n4. **Render Jinja2 template** \u2014 `render_jinja_template()` processes `class_generator_template.j2` with the extracted data, producing a complete Python class.\n5. **Preserve user code** \u2014 If the target file already exists, `parse_user_code_from_file()` extracts any user-added code below the `# End of generated code` marker and re-inserts it after regeneration.\n6. **Format and write** \u2014 `write_and_format_rendered()` writes the file, then formats it with `prek` (the repository's own pre-commit hooks, unmodified). prek runs a second time to reach a fixpoint; if it still fails, the kind fails loudly instead of leaving an unformatted file. No other formatter is invoked, so a file that passes locally also passes CI.\n\n## Key Concepts\n\n### The Resource Mappings File\n\nThe resource mappings file (`__resources-mappings.json.gz`) is the central schema store. It maps lowercase kind names to arrays of schema objects:\n\n```json\n{\n \"pod\": [\n {\n \"description\": \"Pod is a collection of containers...\",\n \"properties\": { ... },\n \"required\": [],\n \"type\": \"object\",\n \"x-kubernetes-group-version-kind\": [\n { \"group\": \"\", \"kind\": \"Pod\", \"version\": \"v1\" }\n ],\n \"namespaced\": true\n }\n ],\n \"ingress\": [\n { \"x-kubernetes-group-version-kind\": [{ \"group\": \"networking.k8s.io\", ... }], ... },\n { \"x-kubernetes-group-version-kind\": [{ \"group\": \"config.openshift.io\", ... }], ... }\n ]\n}\n```\n\nResources with the same kind but different API groups (like `Ingress` above) are stored as separate entries in the array. This structure allows the validator and generator to handle API group disambiguation correctly.\n\n> **Note:** The mappings file is compressed with gzip to reduce repository size. The `archive_utils` module handles transparent compression and decompression via `save_json_archive()` and `load_json_archive()`.\n\n### The Definitions File\n\nThe definitions file (`_definitions.json`) contains detailed schema definitions keyed by `group/version/Kind` paths (e.g., `apps/v1/Deployment`, `v1/Pod`). It also stores referenced sub-schemas like `io.k8s.api.core.v1.PodSpec` that are targets of `$ref` pointers. During validation, the `SchemaValidator` uses this file as the reference store for resolving nested types.\n\n### SchemaValidator\n\n`SchemaValidator` is a class with only class-level methods and caches \u2014 you never need to instantiate it. It serves as the shared validation engine used by both `Resource.validate()` and `Resource.validate_dict()`.\n\n```python\nfrom ocp_resources.utils.schema_validator import SchemaValidator\n```\n\n| Method | Signature | Description |\n|---|---|---|\n| `load_mappings_data` | `(skip_cache: bool = False) -> bool` | Loads the mappings archive and definitions file. Returns `True` on success. |\n| `get_mappings_data` | `(skip_cache: bool = False) -> dict[str, Any] \\| None` | Returns loaded mappings data, loading first if needed. |\n| `get_definitions_data` | `() -> dict[str, Any] \\| None` | Returns loaded definitions data, loading first if needed. |\n| `load_schema` | `(kind: str, api_group: str \\| None = None) -> dict[str, Any] \\| None` | Loads and resolves a complete schema for a kind. Handles API group disambiguation and caching. |\n| `validate` | `(resource_dict: dict[str, Any], kind: str, api_group: str \\| None = None) -> None` | Validates a resource dict. Raises `jsonschema.ValidationError` on failure. |\n| `format_validation_error` | `(error, kind, name, api_group=None) -> str` | Formats a validation error into a user-friendly message with field path and context. |\n| `clear_cache` | `() -> None` | Clears the resolved schema cache (not the raw mappings/definitions data). |\n\n> **Tip:** The `load_schema` method resolves `$ref` references recursively. For core Kubernetes types like `ObjectMeta` or `TypeMeta` that may be missing from definitions, it falls back to a permissive `{\"type\": \"object\", \"additionalProperties\": true}` schema rather than failing outright.\n\n### Resource Validation Methods\n\nThe `Resource` base class exposes two validation methods that delegate to `SchemaValidator`:\n\n```python\nfrom ocp_resources.resource import Resource\n\n# Instance-level validation \u2014 validates self.res\nresource.validate()\n\n# Class-level validation \u2014 validates any dict against the class's schema\nMyResource.validate_dict(resource_dict)\n```\n\nBoth raise `ocp_resources.exceptions.ValidationError` (not the raw `jsonschema.ValidationError`) with formatted error messages that include the resource identifier, field path, and details.\n\n**Auto-validation** can be enabled per-instance by passing `schema_validation_enabled=True` to the constructor:\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(\n name=\"my-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"app\", \"image\": \"nginx\"}],\n client=client,\n schema_validation_enabled=True,\n)\n# validation runs automatically before create() and update_replace()\npod.deploy()\n```\n\nWhen auto-validation is enabled:\n- `create()` calls `self.validate()` after `to_dict()` builds the resource dictionary\n- `update_replace()` calls `validate_dict()` on the replacement resource dictionary\n- `update()` (patch operations) does **not** trigger validation because patches are partial documents that may not pass full schema validation\n\n### Schema Update Strategies\n\nThe `update_kind_schema()` function employs a version-aware update strategy:\n\n| Cluster Version | Behavior |\n|---|---|\n| **Same or newer** than last recorded | Fetches **all** API schemas, updates existing resources, supplements with `oc explain` data |\n| **Older** than last recorded | Identifies only **missing** resources via `identify_missing_resources()`, fetches only relevant API paths, does **not** modify existing schemas |\n\nThis strategy prevents an older cluster from overwriting schemas that contain richer data from a newer cluster.\n\n> **Warning:** If you connect to an older cluster and need to update a specific CRD's schema (for example, after installing a new operator), use `update_single_resource_schema(kind)` or the CLI flag `--update-schema-for `. This bypasses the version guard for that one resource.\n\n### Single-Resource Schema Updates\n\nThe `update_single_resource_schema()` function fetches the schema for exactly one resource kind without affecting other resources in the mapping:\n\n```python\nfrom class_generator.core.schema import update_single_resource_schema\n\nupdate_single_resource_schema(kind=\"LlamaStackDistribution\")\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `kind` | `str` | The resource Kind (case-sensitive, e.g., `\"LlamaStackDistribution\"`) |\n| `client` | `str \\| None` | Client binary path. Auto-detected if `None`. |\n\n**Raises:**\n- `ResourceNotFoundError` \u2014 if the kind is not found on the cluster\n- `RuntimeError` \u2014 if schema fetching fails\n\n### Code Generation Pipeline\n\nThe `class_generator()` function orchestrates the full generation pipeline:\n\n```python\nfrom class_generator.core.generator import class_generator\n\ngenerated_files = class_generator(kind=\"Deployment\", overwrite=True)\n```\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `kind` | `str` | (required) | Kubernetes resource kind |\n| `overwrite` | `bool` | `False` | Overwrite existing files |\n| `dry_run` | `bool` | `False` | Print output without writing |\n| `output_file` | `str` | `\"\"` | Specific output file path |\n| `output_dir` | `str` | `\"\"` | Output directory (defaults to `ocp_resources`) |\n| `add_tests` | `bool` | `False` | Generate test files in `class_generator/tests/manifests/` |\n\n**Returns:** `list[str]` \u2014 paths of generated files.\n\n**Raises:**\n- `RuntimeError` \u2014 if the kind is not found in schema mappings\n- `ValueError` \u2014 if the generated filename contains invalid patterns\n\nWhen a kind exists in multiple API groups (e.g., `Ingress` in both `networking.k8s.io` and `config.openshift.io`), the generator produces **separate files** with a group-based suffix (e.g., `ingress_networking_k8s_io.py`, `ingress_config_openshift_io.py`).\n\n### Generated Class Structure\n\nEvery generated file follows a consistent structure produced by the Jinja2 template:\n\n```python\n# Generated using https://github.com/RedHatQE/openshift-python-wrapper/blob/main/class_generator/README.md\n\nfrom typing import Any\nfrom ocp_resources.resource import NamespacedResource\n\nclass Pod(NamespacedResource):\n \"\"\"Pod is a collection of containers that can run on a host.\"\"\"\n\n api_version: str = NamespacedResource.ApiVersion.V1\n\n def __init__(self, containers: list[Any] | None = None, ..., **kwargs: Any) -> None:\n \"\"\"Args:\n containers (list[Any]): List of containers belonging to the pod.\n ...\n \"\"\"\n super().__init__(**kwargs)\n self.containers = containers\n ...\n\n def to_dict(self) -> None:\n super().to_dict()\n if not self.kind_dict and not self.yaml_file:\n # Required fields raise MissingRequiredArgumentError\n # Optional fields added only if not None\n ...\n # End of generated code\n```\n\nUser-added code placed **after** the `# End of generated code` marker is preserved across regenerations.\n\n### `$ref` Resolution\n\nOpenAPI schemas extensively use `$ref` pointers to reference shared type definitions (e.g., `\"$ref\": \"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta\"`). The `SchemaValidator._resolve_refs()` method handles this by:\n\n1. Parsing the reference path to extract the definition name\n2. Looking up the definition in `_definitions_data` using multiple key formats (full dotted path, short name, or `version/Kind` format)\n3. Recursively resolving any `$ref` pointers within the resolved definition\n4. Falling back to a permissive object schema for well-known core types that may be missing\n\n### Schema Supplementation via `oc explain`\n\nThe raw OpenAPI v3 schemas from the cluster sometimes lack field descriptions and required-field markers. The system supplements these gaps by:\n\n- Running `oc explain .` commands **in parallel** for critical resource types (Pod, Deployment, Service, etc.)\n- Parsing the explain output to extract field descriptions, types, and required-field markers\n- Merging descriptions into existing schema properties where they are missing\n- Detecting missing `$ref` targets and fetching their definitions via `oc explain --recursive`\n\nThis supplementation only runs during a **full update** (same or newer cluster version), not when only fetching missing resources.\n\n## How It Affects You\n\n| What you do | What happens under the hood |\n|---|---|\n| Call `resource.validate()` | `SchemaValidator` loads schemas from the compressed archive, resolves `$ref` references, validates with jsonschema, and raises `ValidationError` with a formatted message |\n| Pass `schema_validation_enabled=True` | `create()` and `update_replace()` automatically validate before sending to the API server |\n| Run `class-generator --update-schema` | Full schema fetch from cluster, version check, parallel API downloads, `oc explain` supplementation, and compressed archive write |\n| Run `class-generator -k Pod` | Reads the mapping for `pod`, parses fields/types, renders Jinja2 template, preserves user code, writes and formats the Python file |\n| Run `class-generator --update-schema-for MyResource` | Single-resource fetch that bypasses the cluster version guard |\n| Run `class-generator --regenerate-all` | Discovers all generated files (by marker comment), regenerates each in parallel, preserving user code |\n| Validation reports \"No schema found\" | The resource kind is not in the mappings archive \u2014 run `--update-schema` to fetch it |\n| Multiple files generated for one kind | The kind exists in multiple API groups \u2014 each group gets its own file with a suffix |\n\n## Related Pages\n\n- See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for a hands-on guide to enabling and using validation.\n- See [Generating New Resource Classes with class-generator](generating-resource-classes.html) for step-by-step class generation instructions.\n- See [class-generator CLI Reference](class-generator-cli.html) for all CLI options including `--update-schema`, `--update-schema-for`, `--regenerate-all`, and `--coverage-report`.\n- See [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html) to learn how generated classes fit into the `Resource` / `NamespacedResource` inheritance tree.\n- See [Resource and NamespacedResource API](resource-api.html) for complete documentation of the `validate()` and `validate_dict()` methods.\n- See [Testing Without a Cluster Using the Fake Client](testing-without-cluster.html) for how the fake client uses schema-based status templates.\n\n## Related Pages\n\n- [Validating Resources Against OpenAPI Schemas](validating-resources.html)\n- [Generating New Resource Classes with class-generator](generating-resource-classes.html)\n- [class-generator CLI Reference](class-generator-cli.html)\n- [Understanding the Resource Class Hierarchy](resource-class-hierarchy.html)\n- [Resource and NamespacedResource API](resource-api.html)\n"}, {"slug": "working-with-events", "title": "Working with Kubernetes Events", "content": "# Working with Kubernetes Events\n\nMonitor, retrieve, and clean up Kubernetes events to debug resource behavior and automate event-driven workflows using the `Event` class and the `resource.events()` method.\n\n## Prerequisites\n\n- A connected Kubernetes/OpenShift client \u2014 see [Connecting to Clusters](connecting-to-clusters.html)\n- The `ocp_resources` package installed \u2014 see [Installing and Creating Your First Resource](quickstart.html)\n\n## Quick Example\n\nStream events from a namespace in real-time:\n\n```python\nfrom ocp_resources.event import Event\n\nfor event in Event.get(client=client, namespace=\"my-namespace\", timeout=30):\n print(f\"{event['type']}: {event.object.reason} \u2014 {event.object.message}\")\n```\n\n## Streaming Events in Real-Time with `Event.get()`\n\n`Event.get()` opens a watch connection and yields events as they occur. It's a generator that blocks until the timeout expires or you break out of the loop.\n\n```python\nfrom ocp_resources.event import Event\n\nfor event in Event.get(\n client=client,\n namespace=\"my-namespace\",\n timeout=60,\n):\n obj = event.object\n print(f\"[{obj.type}] {obj.reason}: {obj.message}\")\n```\n\nEach yielded event is a watch event dictionary with these keys:\n\n| Key | Description |\n|----------------|--------------------------------------------------------------------|\n| `type` | Watch event type: `ADDED`, `MODIFIED`, or `DELETED` |\n| `object` | The Event resource object (access `.type`, `.reason`, `.message`, `.involvedObject`, etc.) |\n| `raw_object` | The raw dictionary representation of the event |\n\n### `Event.get()` Parameters\n\n| Parameter | Type | Default | Description |\n|--------------------|-------------------------|----------|-----------------------------------------------------------|\n| `client` | `DynamicClient` | `None` | Kubernetes dynamic client (required) |\n| `namespace` | `str \\| None` | `None` | Filter events to a specific namespace |\n| `name` | `str \\| None` | `None` | Filter by event name |\n| `label_selector` | `str \\| None` | `None` | Filter by labels (e.g. `\"app=nginx\"`) |\n| `field_selector` | `str \\| None` | `None` | Filter by fields (e.g. `\"type==Warning\"`) |\n| `resource_version` | `str \\| None` | `None` | Start watching from a specific resource version |\n| `timeout` | `int \\| None` | `None` | Timeout in seconds; `None` watches indefinitely |\n\n**Returns:** `Generator` \u2014 yields watch event dictionaries.\n\n### Filtering with Field Selectors\n\nField selectors let you narrow down events to exactly what you care about. Combine multiple selectors with commas:\n\n```python\n# Only Warning events for ClusterServiceVersion resources\nfor event in Event.get(\n client=client,\n namespace=\"my-namespace\",\n field_selector=\"involvedObject.kind==ClusterServiceVersion,type==Warning,reason==AnEventReason\",\n timeout=10,\n):\n print(event.object.message)\n```\n\nCommon field selector fields:\n\n| Field | Example |\n|------------------------------|-------------------------------------------------|\n| `type` | `type==Warning` or `type==Normal` |\n| `reason` | `reason==FailedScheduling` |\n| `involvedObject.kind` | `involvedObject.kind==Pod` |\n| `involvedObject.name` | `involvedObject.name==my-pod` |\n| `involvedObject.namespace` | `involvedObject.namespace==default` |\n\n## Listing Existing Events with `Event.list()`\n\nUnlike `Event.get()`, which streams events in real-time, `Event.list()` returns existing events immediately as a list. By default it only returns events from the last 5 minutes.\n\n```python\nfrom ocp_resources.event import Event\n\n# List all Warning events from the last 5 minutes\nevents = Event.list(\n client=client,\n namespace=\"my-namespace\",\n field_selector=\"type==Warning\",\n)\n\nfor event in events:\n print(f\"{event.reason}: {event.message}\")\n```\n\nResults are sorted by `lastTimestamp` descending (most recent first).\n\n### `Event.list()` Parameters\n\n| Parameter | Type | Default | Description |\n|------------------|---------------------|---------|-----------------------------------------------------------|\n| `client` | `DynamicClient` | \u2014 | Kubernetes dynamic client (required) |\n| `namespace` | `str \\| None` | `None` | Filter events to a specific namespace |\n| `field_selector` | `str \\| None` | `None` | Filter by fields (e.g. `\"type==Warning\"`) |\n| `label_selector` | `str \\| None` | `None` | Filter by labels |\n| `since_seconds` | `int` | `300` | Only return events from the last N seconds |\n\n**Returns:** `list` \u2014 event resource objects sorted by timestamp (most recent first).\n\n**Raises:** `ValueError` \u2014 if `since_seconds` is negative.\n\n```python\n# Events from the last 30 minutes\nevents = Event.list(client=client, since_seconds=1800)\n\n# All events (no time filter \u2014 uses a very large window)\nevents = Event.list(client=client, since_seconds=999999)\n```\n\n### When to Use `Event.get()` vs `Event.list()`\n\n| Use case | Method |\n|--------------------------------------------------|----------------|\n| Watch for new events as they happen | `Event.get()` |\n| Fetch events that already occurred | `Event.list()` |\n| Collect events during a test run | `Event.get()` |\n| Check recent events after a failure | `Event.list()` |\n| Stream events with a timeout | `Event.get()` |\n| Get a snapshot sorted by time | `Event.list()` |\n\n## Getting Events for a Specific Resource\n\nEvery resource instance has an `.events()` method that automatically filters events to that specific resource by setting `involvedObject.name` in the field selector.\n\n```python\nfrom ocp_resources.pod import Pod\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\nfor event in pod.events(timeout=10):\n print(f\"{event.object.reason}: {event.object.message}\")\n```\n\nYou can add extra filters on top of the automatic resource filter:\n\n```python\n# Only Warning events for this specific pod\nfor event in pod.events(\n field_selector=\"type==Warning\",\n timeout=10,\n):\n print(event.object.message)\n```\n\n### `resource.events()` Parameters\n\n| Parameter | Type | Default | Description |\n|--------------------|---------|---------|-----------------------------------------------------------|\n| `name` | `str` | `\"\"` | Filter by event name |\n| `label_selector` | `str` | `\"\"` | Filter by labels |\n| `field_selector` | `str` | `\"\"` | Additional field selectors (combined with `involvedObject.name` automatically) |\n| `resource_version` | `str` | `\"\"` | Start watching from a specific resource version |\n| `timeout` | `int` | `240` | Timeout in seconds (default: 4 minutes) |\n\n**Returns:** `Generator` \u2014 yields watch event dictionaries, same format as `Event.get()`.\n\n> **Note:** The `field_selector` you provide is appended to the automatic `involvedObject.name==` filter. You don't need to specify the resource name yourself.\n\n## Deleting Events with `Event.delete_events()`\n\nClean up events before a test run to avoid false positives from stale events:\n\n```python\nfrom ocp_resources.event import Event\n\n# Delete all events in a namespace\nEvent.delete_events(client=client, namespace=\"my-namespace\")\n\n# Delete events matching a specific reason\nEvent.delete_events(\n client=client,\n namespace=\"my-namespace\",\n field_selector=\"reason==AnEventReason\",\n)\n```\n\n### `Event.delete_events()` Parameters\n\n| Parameter | Type | Default | Description |\n|--------------------|-------------------------|----------|-----------------------------------------------------------|\n| `client` | `DynamicClient` | `None` | Kubernetes dynamic client (required) |\n| `namespace` | `str \\| None` | `None` | Target namespace |\n| `name` | `str \\| None` | `None` | Specific event name to delete |\n| `label_selector` | `str \\| None` | `None` | Filter by labels |\n| `field_selector` | `str \\| None` | `None` | Filter by fields |\n| `resource_version` | `str \\| None` | `None` | Filter by resource version |\n| `timeout` | `int \\| None` | `None` | Timeout in seconds |\n\n**Returns:** `None`\n\n## Advanced Usage\n\n### Test Setup: Clean Events Before Each Test\n\nA common pattern is deleting events before a test so that only events generated during the test are captured:\n\n```python\nfrom ocp_resources.event import Event\n\n\ndef test_pod_scheduling(client, namespace):\n # Clean slate \u2014 remove old events\n Event.delete_events(client=client, namespace=namespace)\n\n # ... create resources and trigger the behavior under test ...\n\n # Verify expected events occurred\n events = Event.list(\n client=client,\n namespace=namespace,\n field_selector=\"reason==Scheduled\",\n since_seconds=60,\n )\n assert len(events) > 0, \"Pod was not scheduled\"\n```\n\n### Collecting Events During an Operation\n\nUse `Event.get()` with a timeout to capture all events that occur during an operation:\n\n```python\nfrom ocp_resources.event import Event\n\nevents = []\nfor event in Event.get(\n client=client,\n namespace=\"my-namespace\",\n field_selector=\"involvedObject.kind==Deployment\",\n timeout=30,\n):\n events.append(event.object)\n if event.object.reason == \"ScalingReplicaSet\":\n break # Found what we were looking for\n\nprint(f\"Captured {len(events)} events\")\n```\n\n### Watching Cluster-Wide Events\n\nOmit the `namespace` parameter to watch events across all namespaces:\n\n```python\nfrom ocp_resources.event import Event\n\n# Watch all Warning events cluster-wide\nfor event in Event.get(\n client=client,\n field_selector=\"type==Warning\",\n timeout=60,\n):\n ns = event.object.involvedObject.get(\"namespace\", \"cluster-scoped\")\n print(f\"[{ns}] {event.object.reason}: {event.object.message}\")\n```\n\n### Accessing Event Object Properties\n\nThe `event.object` yielded by `Event.get()` and `resource.events()` is a Kubernetes `ResourceInstance` with these commonly used attributes:\n\n| Attribute | Description |\n|-------------------------------|----------------------------------------------|\n| `event.object.type` | `\"Normal\"` or `\"Warning\"` |\n| `event.object.reason` | Short reason string (e.g. `\"Scheduled\"`) |\n| `event.object.message` | Human-readable event message |\n| `event.object.count` | Number of times this event occurred |\n| `event.object.firstTimestamp` | When the event first occurred |\n| `event.object.lastTimestamp` | When the event most recently occurred |\n| `event.object.source` | Dict with `component` and `host` |\n| `event.object.involvedObject` | Dict with `kind`, `name`, `namespace`, etc. |\n\n## Troubleshooting\n\n**`Event.get()` hangs indefinitely**\nYou likely omitted the `timeout` parameter. Always set a `timeout` value to avoid blocking forever:\n\n```python\n# Bad \u2014 hangs if no events arrive\nfor event in Event.get(client=client, namespace=\"ns\"):\n ...\n\n# Good \u2014 stops after 30 seconds\nfor event in Event.get(client=client, namespace=\"ns\", timeout=30):\n ...\n```\n\n**`Event.list()` returns an empty list**\n- Check the `since_seconds` value. The default is 300 (5 minutes). If the events are older, increase the value.\n- Verify the `namespace` is correct.\n- Ensure the `field_selector` syntax uses `==` (double equals), not `=`.\n\n**`resource.events()` returns events for other resources too**\nThis method filters only by `involvedObject.name`. If multiple resources share the same name across different kinds, add a `field_selector` to narrow it down:\n\n```python\nfor event in pod.events(\n field_selector=\"involvedObject.kind==Pod\",\n timeout=10,\n):\n print(event.object.message)\n```\n\n> **Warning:** The `dyn_client` parameter on `Event.get()` and `Event.delete_events()` is deprecated and will be removed in the next major release. Use `client` instead.\n\n## Related Pages\n\n- [Querying and Watching Resources](querying-resources.html)\n- [Common Resource Patterns](common-patterns.html)\n- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n"}, {"slug": "pod-execution-and-logs", "title": "Executing Commands in Pods and Retrieving Logs", "content": "# Executing Commands in Pods and Retrieving Logs\n\nRun commands inside running pods and retrieve their logs to debug and interact with workloads on your OpenShift or Kubernetes cluster.\n\n## Prerequisites\n\n- A connected cluster client (see [Connecting to Clusters](connecting-to-clusters.html))\n- An existing, running pod you want to interact with\n\n## Quick Example\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\npod = Pod(client=client, name=\"my-app\", namespace=\"default\")\n\n# Run a command inside the pod\noutput = pod.execute(command=[\"echo\", \"hello\"])\nprint(output) # \"hello\\n\"\n\n# Get pod logs\nlogs = pod.log()\nprint(logs)\n```\n\n## Executing Commands with `Pod.execute()`\n\n`Pod.execute()` runs a command inside a pod container using the Kubernetes exec API and returns the standard output as a string.\n\n### Method Signature\n\n```python\nPod.execute(\n command: list[str],\n timeout: int = 60,\n container: str = \"\",\n ignore_rc: bool = False,\n) -> str\n```\n\n| Parameter | Type | Default | Description |\n|-------------|--------------|---------|---------------------------------------------------------------------------------------------|\n| `command` | `list[str]` | \u2014 | The command to run, as a list of strings (e.g., `[\"ls\", \"-la\", \"/tmp\"]`) |\n| `timeout` | `int` | `60` | Maximum seconds to wait for the command to complete |\n| `container` | `str` | `\"\"` | Container name to execute in. If empty, uses the first container in the pod spec |\n| `ignore_rc` | `bool` | `False` | When `True`, return stdout even if the command exits with a non-zero return code |\n\n**Returns:** `str` \u2014 the standard output of the command.\n\n**Raises:** `ExecOnPodError` \u2014 when the command fails (non-zero exit code) and `ignore_rc` is `False`.\n\n### Step-by-Step: Running a Simple Command\n\n1. Get a reference to the pod:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\npod = Pod(client=client, name=\"my-app\", namespace=\"my-namespace\")\n```\n\n2. Execute a command:\n\n```python\noutput = pod.execute(command=[\"cat\", \"/etc/hostname\"])\nprint(output)\n```\n\n3. Use the result for further logic:\n\n```python\nfiles = pod.execute(command=[\"ls\", \"/app/data\"])\nfor filename in files.strip().split(\"\\n\"):\n print(f\"Found: {filename}\")\n```\n\n### Selecting a Container\n\nFor multi-container pods, specify which container to run the command in. If you omit `container`, the first container defined in the pod spec is used.\n\n```python\n# Execute in a specific container\noutput = pod.execute(\n command=[\"cat\", \"/var/log/app.log\"],\n container=\"sidecar\",\n)\n```\n\n### Setting a Timeout\n\nLong-running commands may need a longer timeout. The default is 60 seconds.\n\n```python\n# Allow up to 5 minutes for a heavy operation\noutput = pod.execute(\n command=[\"pg_dump\", \"mydb\"],\n timeout=300,\n)\n```\n\nWhen the timeout is exceeded, an `ExecOnPodError` is raised with the error message `\"stream resp is closed\"`.\n\n### Ignoring Non-Zero Exit Codes\n\nSome commands return a non-zero exit code as part of normal operation (e.g., `grep` returns 1 when no match is found). Use `ignore_rc=True` to get the output regardless:\n\n```python\n# grep returns exit code 1 when there's no match \u2014 don't treat that as an error\noutput = pod.execute(\n command=[\"grep\", \"ERROR\", \"/var/log/app.log\"],\n ignore_rc=True,\n)\nif output:\n print(\"Errors found:\", output)\nelse:\n print(\"No errors in logs\")\n```\n\n### Handling Errors with `ExecOnPodError`\n\nWhen a command fails, `ExecOnPodError` provides structured access to the failure details.\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ExecOnPodError\n\ntry:\n pod.execute(command=[\"ls\", \"/nonexistent\"])\nexcept ExecOnPodError as e:\n print(f\"Command: {e.cmd}\") # ['ls', '/nonexistent']\n print(f\"Return code: {e.rc}\") # Non-zero exit code (or -1 for stream errors)\n print(f\"Stdout: {e.out}\") # Standard output captured before failure\n print(f\"Stderr: {e.err}\") # Standard error or error channel details\n```\n\n`ExecOnPodError` attributes:\n\n| Attribute | Type | Description |\n|-----------|-------------|----------------------------------------------------------------------|\n| `cmd` | `list[str]` | The command that was executed |\n| `rc` | `int` | Return code (`-1` for stream/timeout errors, otherwise the exit code)|\n| `out` | `str` | Standard output captured from the command |\n| `err` | `str` or `dict` | Standard error output, or the Kubernetes error channel response |\n\n> **Tip:** For a complete list of all custom exceptions, see [Error Handling and Exception Reference](error-handling.html).\n\n## Retrieving Logs with `Pod.log()`\n\n`Pod.log()` returns the logs from a pod container as a string. It passes keyword arguments directly to the Kubernetes `read_namespaced_pod_log` API.\n\n### Basic Usage\n\n```python\nlogs = pod.log()\nprint(logs)\n```\n\n### Common Keyword Arguments\n\nPass any parameter supported by the Kubernetes `read_namespaced_pod_log` API:\n\n| Parameter | Type | Description |\n|-----------------|--------|-----------------------------------------------------------|\n| `container` | `str` | Container name to get logs from (required for multi-container pods) |\n| `previous` | `bool` | Return logs from a previous terminated container instance |\n| `tail_lines` | `int` | Number of lines from the end of the logs to return |\n| `since_seconds` | `int` | Only return logs newer than this many seconds |\n\n### Examples\n\n```python\n# Get logs from a specific container\nlogs = pod.log(container=\"nginx\")\n\n# Get the last 50 lines\nlogs = pod.log(tail_lines=50)\n\n# Get logs from the last 5 minutes\nlogs = pod.log(since_seconds=300)\n\n# Get logs from a crashed container's previous instance\nlogs = pod.log(container=\"app\", previous=True)\n```\n\n## Accessing Pod Properties\n\nBeyond executing commands and reading logs, the `Pod` class provides properties for inspecting the pod's runtime state.\n\n### Getting the Node\n\nThe `node` property returns a `Node` object for the node where the pod is scheduled:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nfor pod in Pod.get(client=client, label_selector=\"app=my-app\"):\n node = pod.node\n print(f\"Pod {pod.name} is running on node {node.name}\")\n```\n\n> **Note:** The `node` property raises an `AssertionError` if the pod has not yet been scheduled to a node.\n\n### Getting the Pod IP Address\n\nThe `ip` property returns the pod's IP address from its status:\n\n```python\npod_ip = pod.ip\nprint(f\"Pod IP: {pod_ip}\")\n```\n\n### Getting the Pod Status\n\nThe `status` property (inherited from the base resource class) returns the pod's phase:\n\n```python\nprint(f\"Pod status: {pod.status}\") # e.g., \"Running\", \"Pending\", \"Succeeded\"\n```\n\n## Advanced Usage\n\n### Iterating Over Pods with Selectors\n\nCombine `Pod.get()` with `execute()` or `log()` to debug across multiple pods:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ExecOnPodError\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nfor pod in Pod.get(client=client, namespace=\"production\", label_selector=\"app=web\"):\n try:\n uptime = pod.execute(command=[\"uptime\"])\n print(f\"{pod.name} on {pod.node.name}: {uptime.strip()}\")\n except ExecOnPodError as e:\n print(f\"{pod.name}: command failed \u2014 {e}\")\n```\n\n### Executing in Multi-Container Pods\n\nWhen pods contain sidecar containers (e.g., logging or proxy containers), always specify the target container explicitly:\n\n```python\n# Main application container\napp_output = pod.execute(command=[\"cat\", \"/app/config.yaml\"], container=\"app\")\n\n# Envoy sidecar\nproxy_stats = pod.execute(command=[\"curl\", \"localhost:15000/stats\"], container=\"istio-proxy\")\n\n# Logs from each container\napp_logs = pod.log(container=\"app\", tail_lines=100)\nproxy_logs = pod.log(container=\"istio-proxy\", tail_lines=100)\n```\n\n### Collecting Debug Information\n\nA practical recipe for gathering debugging data from a running pod:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ExecOnPodError\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\npod = Pod(client=client, name=\"my-app\", namespace=\"default\")\n\ndebug_info = {\n \"pod_name\": pod.name,\n \"node\": pod.node.name,\n \"ip\": pod.ip,\n \"status\": pod.status,\n \"logs_tail\": pod.log(tail_lines=20),\n}\n\n# Safely attempt commands that might fail\nfor cmd_name, cmd in [(\"env\", [\"env\"]), (\"df\", [\"df\", \"-h\"]), (\"ps\", [\"ps\", \"aux\"])]:\n try:\n debug_info[cmd_name] = pod.execute(command=cmd, timeout=10)\n except ExecOnPodError:\n debug_info[cmd_name] = \"command failed\"\n\nfor key, value in debug_info.items():\n print(f\"--- {key} ---\\n{value}\\n\")\n```\n\n### Using Pod Context Manager for Temporary Pods\n\nCreate a pod, run commands, and clean up automatically:\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.resource import get_client\n\nclient = get_client()\n\nwith Pod(\n client=client,\n name=\"debug-pod\",\n namespace=\"default\",\n containers=[\n {\n \"name\": \"debug\",\n \"image\": \"registry.access.redhat.com/ubi9/ubi:latest\",\n \"command\": [\"sleep\", \"3600\"],\n }\n ],\n) as pod:\n pod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)\n output = pod.execute(command=[\"cat\", \"/etc/os-release\"])\n print(output)\n# Pod is automatically deleted when the context manager exits\n```\n\n> **Note:** See [Creating and Managing Resources](creating-and-managing-resources.html) for more about context managers and resource lifecycle.\n\n## Troubleshooting\n\n### `ExecOnPodError` with `\"stream resp is closed\"`\n\nThis typically means the command timed out. Increase the `timeout` parameter:\n\n```python\npod.execute(command=[\"long-running-script.sh\"], timeout=300)\n```\n\n### `AssertionError: Node not found for pod `\n\nThe pod is not yet scheduled. Wait for it to be running before accessing `pod.node`:\n\n```python\npod.wait_for_status(status=Pod.Status.RUNNING, timeout=120)\nnode = pod.node\n```\n\n### `ExecOnPodError` with return code `-1` and an error channel dict\n\nThis indicates a Kubernetes-level failure (e.g., container not found, pod not running). Check that:\n\n- The pod is in `Running` status\n- The `container` name matches one defined in the pod spec\n- The container has started and is not in `CrashLoopBackOff`\n\n### Logs return empty string\n\nThe container may not have produced any output yet, or it may have restarted. Try:\n\n```python\n# Check previous container instance\nlogs = pod.log(previous=True)\n\n# Or narrow to recent logs\nlogs = pod.log(since_seconds=60)\n```\n\n## Related Pages\n\n- [Common Resource Patterns](common-patterns.html)\n- [Working with Kubernetes Events](working-with-events.html)\n- [Error Handling and Exception Reference](error-handling.html)\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Working with Virtual Machines (KubeVirt)](working-with-virtual-machines.html)\n"}, {"slug": "error-handling", "title": "Error Handling and Exception Reference", "content": "# Error Handling and Exception Reference\n\nAll custom exceptions are defined in `ocp_resources.exceptions` and can be imported directly:\n\n```python\nfrom ocp_resources.exceptions import (\n ExecOnPodError,\n MissingRequiredArgumentError,\n ResourceTeardownError,\n ValidationError,\n ConditionError,\n NNCPConfigurationFailed,\n ClientWithBasicAuthError,\n MissingResourceError,\n MissingTemplateVariables,\n)\n```\n\n> **Tip:** All exceptions inherit from Python's built-in `Exception` class and can be caught with a bare `except Exception` if needed.\n\n---\n\n## ExecOnPodError\n\nRaised when a command executed inside a pod via `Pod.execute()` fails. See [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html) for full usage details.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import ExecOnPodError\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `command` | `list[str]` | The command that was executed |\n| `rc` | `int` | Return code (`-1` if the return code could not be determined) |\n| `out` | `str` | Standard output captured from the command |\n| `err` | `Any` | Standard error output or error channel details |\n\n**Attributes on the caught exception:**\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `cmd` | `list[str]` | The command that was executed |\n| `rc` | `int` | The return code |\n| `out` | `str` | Standard output |\n| `err` | `Any` | Standard error or error channel dict |\n\n**Raised by:** `Pod.execute()` in the following scenarios:\n\n- Command times out (stream response closes before completion) \u2014 `rc=-1`\n- Error channel returns no status \u2014 `rc=-1`\n- Error channel status is `\"Failure\"` \u2014 `rc=-1`, `err` contains the full error channel dict\n- Command exits with a non-zero exit code \u2014 `rc` contains the actual exit code\n\n**String representation:**\n\n```\nCommand execution failure: ['ls', '/nonexistent'], RC: 2, OUT: , ERR: ls: cannot access '/nonexistent'\n```\n\n**Example:**\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ExecOnPodError\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n\ntry:\n output = pod.execute(command=[\"ls\", \"/nonexistent\"], timeout=30)\nexcept ExecOnPodError as e:\n print(f\"Command: {e.cmd}\")\n print(f\"Return code: {e.rc}\")\n print(f\"Stdout: {e.out}\")\n print(f\"Stderr: {e.err}\")\n```\n\n**Handling non-zero exit codes without exceptions:**\n\n```python\n# Use ignore_rc=True to suppress ExecOnPodError on non-zero exit codes\noutput = pod.execute(command=[\"grep\", \"pattern\", \"/var/log/messages\"], ignore_rc=True)\n```\n\n---\n\n## MissingRequiredArgumentError\n\nRaised when a resource is instantiated without providing required arguments and no `yaml_file` or `kind_dict` was supplied. This error is raised during `to_dict()`, which is called automatically by `create()` and `deploy()`.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import MissingRequiredArgumentError\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `argument` | `str` | Name of the missing required argument(s) |\n\n**Attributes on the caught exception:**\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `argument` | `str` | The missing argument name |\n\n**String representation:**\n\n```\nMissing required argument/s. Either provide yaml_file, kind_dict or pass self.containers\n```\n\n**Raised by:** The `to_dict()` method of resource subclasses when required spec fields are not provided. Examples of resources that raise this exception:\n\n| Resource Class | Required Arguments |\n|---|---|\n| `Pod` | `containers` |\n| `StorageClass` | `provisioner` |\n| `ClusterRoleBinding` | `cluster_role` |\n| `ClusterResourceQuota` | `quota`, `selector` |\n| `CronJob` | `job_template`, `schedule` |\n| `InferenceService` | `predictor` |\n| `IPAddressPool` | `addresses` |\n| `ResourceQuota` | `hard` |\n| `UserDefinedNetwork` | `topology` |\n\n> **Note:** This exception is **not** raised if you construct the resource using `yaml_file` or `kind_dict`, since those bypass the `to_dict()` logic.\n\n**Example:**\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import MissingRequiredArgumentError\n\ntry:\n pod = Pod(client=client, name=\"my-pod\", namespace=\"default\")\n pod.deploy()\nexcept MissingRequiredArgumentError as e:\n print(f\"Missing: {e.argument}\")\n # Output: Missing required argument/s. Either provide yaml_file, kind_dict or pass self.containers\n```\n\n---\n\n## ResourceTeardownError\n\nRaised when a resource's `clean_up()` method returns `False` during context manager exit. This indicates the resource could not be deleted.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import ResourceTeardownError\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `resource` | `Any` | The resource object that failed to tear down |\n\n**Attributes on the caught exception:**\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `resource` | `Any` | The resource object that failed teardown |\n\n**String representation:**\n\n```\nFailed to execute teardown for resource \n```\n\n**Raised by:** `Resource.__exit__()` \u2014 the context manager exit handler. Specifically, this is raised when:\n\n1. The resource was created with `teardown=True` (the default).\n2. The `clean_up()` method returns `False`.\n\n**Example:**\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ResourceTeardownError\n\ntry:\n with Pod(\n client=client,\n name=\"my-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n ) as pod:\n # Use pod...\n pass\n # clean_up() is called automatically here\nexcept ResourceTeardownError as e:\n print(f\"Could not delete: {e.resource}\")\n```\n\n> **Tip:** Set `teardown=False` on the resource constructor if you do not want automatic cleanup on context manager exit, and thus never want this exception raised.\n\n---\n\n## ValidationError\n\nRaised when a resource fails schema validation against the OpenAPI specification. See [Validating Resources Against OpenAPI Schemas](validating-resources.html) for full validation guide.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import ValidationError\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `message` | `str` | *(required)* | Human-readable error description |\n| `path` | `str` | `\"\"` | JSONPath to the invalid field (e.g., `\"spec.containers[0].image\"`) |\n| `schema_error` | `Any` | `None` | Original `jsonschema` validation error for debugging |\n\n**Attributes on the caught exception:**\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `message` | `str` | Human-readable error message |\n| `path` | `str` | JSONPath to the invalid field |\n| `schema_error` | `Any` | Original `jsonschema.ValidationError` if available |\n\n**String representation:**\n\n```\nValidation error at 'spec.containers[0].image': Invalid type\n```\n\nWhen `path` is empty:\n\n```\nValidation error: Field is required\n```\n\n**Raised by:**\n\n| Method | Trigger |\n|--------|---------|\n| `resource.validate()` | Called explicitly by user |\n| `resource.create()` | When `schema_validation_enabled=True` |\n| `resource.update_replace()` | When `schema_validation_enabled=True` |\n| `Resource.validate_dict()` | Class method for validating raw dicts |\n\n> **Note:** `resource.update()` does **not** trigger validation even when `schema_validation_enabled=True`, because updates send partial patches that would fail full schema validation.\n\n**Example \u2014 explicit validation:**\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ValidationError\n\npod = Pod(client=client, name=\"my-pod\", namespace=\"default\", containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}])\n\ntry:\n pod.validate()\nexcept ValidationError as e:\n print(f\"Error: {e.message}\")\n print(f\"Path: {e.path}\")\n if e.schema_error:\n print(f\"Original error: {e.schema_error}\")\n```\n\n**Example \u2014 auto-validation on create:**\n\n```python\nfrom ocp_resources.pod import Pod\nfrom ocp_resources.exceptions import ValidationError\n\ntry:\n pod = Pod(\n client=client,\n name=\"my-pod\",\n namespace=\"default\",\n containers=[{\"name\": \"nginx\", \"image\": \"nginx:latest\"}],\n schema_validation_enabled=True,\n )\n pod.deploy()\nexcept ValidationError as e:\n print(f\"Invalid resource: {e}\")\n```\n\n**Example \u2014 validate a raw dictionary:**\n\n```python\nfrom ocp_resources.deployment import Deployment\nfrom ocp_resources.exceptions import ValidationError\n\ndeployment_dict = {\n \"apiVersion\": \"apps/v1\",\n \"kind\": \"Deployment\",\n \"metadata\": {\"name\": \"my-deploy\"},\n \"spec\": {\"replicas\": \"three\"}, # Wrong type \u2014 should be int\n}\n\ntry:\n Deployment.validate_dict(resource_dict=deployment_dict)\nexcept ValidationError as e:\n print(f\"Validation failed: {e}\")\n```\n\n---\n\n## ConditionError\n\nRaised when a resource reaches an undesired stop condition during `wait_for_condition()`. See [Waiting for Resource Conditions and Status](waiting-for-conditions.html) for details.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import ConditionError\n```\n\n**Constructor Parameters:** Standard `Exception` \u2014 accepts a single string message.\n\n**Raised by:** `Resource.wait_for_condition()` when the `stop_condition` parameter matches before the desired condition is met.\n\n**`wait_for_condition()` parameters relevant to `ConditionError`:**\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `condition` | `str` | *(required)* | Condition type to wait for |\n| `status` | `str` | *(required)* | Expected status value |\n| `timeout` | `int` | `300` | Maximum wait time in seconds |\n| `sleep_time` | `int` | `1` | Polling interval in seconds |\n| `stop_condition` | `str \\| None` | `None` | Condition type that should abort the wait |\n| `stop_status` | `str` | `\"True\"` | Status value for the stop condition |\n\n**String representation:**\n\n```\nDeployment my-deploy reached stop_condition 'Failed' in status 'True':\n{'type': 'Failed', 'status': 'True', 'reason': 'DeadlineExceeded', 'message': '...'}\n```\n\n> **Note:** When `stop_condition` is `None` (the default), `ConditionError` is never raised. Instead, `TimeoutExpiredError` is raised if the desired condition is not met within the timeout.\n\n**Example:**\n\n```python\nfrom ocp_resources.deployment import Deployment\nfrom ocp_resources.exceptions import ConditionError\nfrom timeout_sampler import TimeoutExpiredError\n\ndeploy = Deployment(client=client, name=\"my-deploy\", namespace=\"default\")\n\ntry:\n deploy.wait_for_condition(\n condition=\"Available\",\n status=\"True\",\n timeout=120,\n stop_condition=\"Failed\",\n stop_status=\"True\",\n )\nexcept ConditionError as e:\n print(f\"Resource entered failure state: {e}\")\nexcept TimeoutExpiredError:\n print(\"Timed out waiting for condition\")\n```\n\n---\n\n## NNCPConfigurationFailed\n\nRaised when a `NodeNetworkConfigurationPolicy` (NNCP) fails to configure on one or more nodes.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import NNCPConfigurationFailed\n```\n\n**Constructor Parameters:** Standard `Exception` \u2014 accepts a single string message describing the failure reason and error details.\n\n**Raised by:** `NodeNetworkConfigurationPolicy.wait_for_status_success()` in two scenarios:\n\n| Scenario | Message Pattern |\n|----------|-----------------|\n| No matching node found | `\"{name}. Reason: NoMatchingNode\"` |\n| Configuration failed on nodes | `\"Reason: FailedToConfigure\\n{error_details}\"` |\n\n> **Note:** When `wait_for_status_success()` catches `TimeoutExpiredError` or `NNCPConfigurationFailed`, it logs the error with node details and re-raises the exception.\n\n**Example:**\n\n```python\nfrom ocp_resources.node_network_configuration_policy import NodeNetworkConfigurationPolicy\nfrom ocp_resources.exceptions import NNCPConfigurationFailed\nfrom timeout_sampler import TimeoutExpiredError\n\nnncp = NodeNetworkConfigurationPolicy(\n client=client,\n name=\"my-nncp\",\n desired_state={\"interfaces\": [{\"name\": \"eth1\", \"type\": \"ethernet\", \"state\": \"up\"}]},\n)\n\ntry:\n nncp.deploy()\n nncp.wait_for_status_success()\nexcept NNCPConfigurationFailed as e:\n print(f\"NNCP configuration failed: {e}\")\nexcept TimeoutExpiredError:\n print(\"NNCP configuration timed out\")\n```\n\n---\n\n## ClientWithBasicAuthError\n\nRaised during OAuth-based client authentication when using username/password credentials to connect to an OpenShift cluster. See [Connecting to Clusters](connecting-to-clusters.html) for connection methods.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import ClientWithBasicAuthError\n```\n\n**Constructor Parameters:** Standard `Exception` \u2014 accepts a single string message.\n\n**Raised by:** `client_configuration_with_basic_auth()` in the following scenarios:\n\n| Scenario | Message |\n|----------|---------|\n| OAuth well-known endpoint not reachable | `\"No well-known file found at endpoint\"` |\n| Authorization code not returned after login | `\"No authorization code found\"` |\n| No `authorization_endpoint` in OAuth config | `\"No authorization_endpoint found in well-known file\"` |\n| Token exchange fails | `\"Failed to authenticate with basic auth\"` |\n\n**Example:**\n\n```python\nfrom ocp_resources.exceptions import ClientWithBasicAuthError\nfrom ocp_resources.resource import client_configuration_with_basic_auth\n\nimport kubernetes\n\nconfiguration = kubernetes.client.Configuration()\nconfiguration.verify_ssl = False\n\ntry:\n api_client = client_configuration_with_basic_auth(\n username=\"admin\",\n password=\"password\",\n host=\"https://api.cluster.example.com:6443\",\n configuration=configuration,\n )\nexcept ClientWithBasicAuthError as e:\n print(f\"Authentication failed: {e}\")\n```\n\n---\n\n## MissingResourceError\n\nRaised when a resource object fails to generate its internal representation.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import MissingResourceError\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `name` | `str` | Name of the resource that could not be generated |\n\n**Attributes on the caught exception:**\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `resource_name` | `str` | The resource name |\n\n**String representation:**\n\n```\nFailed to generate resource: my-resource\n```\n\n**Example:**\n\n```python\nfrom ocp_resources.exceptions import MissingResourceError\n\ntry:\n # Operations that may fail to generate a resource\n ...\nexcept MissingResourceError as e:\n print(f\"Resource generation failed: {e.resource_name}\")\n```\n\n---\n\n## MissingTemplateVariables\n\nRaised when rendering a YAML template and not all required template variables have been provided.\n\n**Import:**\n\n```python\nfrom ocp_resources.exceptions import MissingTemplateVariables\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `var` | `str` | Name of the missing template variable |\n| `template` | `str` | Path to the template file |\n\n**Attributes on the caught exception:**\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `var` | `str` | The missing variable name |\n| `template` | `str` | The template file path |\n\n**String representation:**\n\n```\nMissing variables image for template tests/manifests/vm.yaml\n```\n\n**Example:**\n\n```python\nfrom ocp_resources.exceptions import MissingTemplateVariables\n\ntry:\n result = generate_yaml_from_template(name=\"my-vm\")\n # If template also requires 'image', raises MissingTemplateVariables\nexcept MissingTemplateVariables as e:\n print(f\"Variable '{e.var}' missing in template '{e.template}'\")\n```\n\n---\n\n## Common Error-Handling Patterns\n\n### Catching Multiple Library Exceptions\n\n```python\nfrom ocp_resources.exceptions import (\n ExecOnPodError,\n MissingRequiredArgumentError,\n ResourceTeardownError,\n ValidationError,\n)\n\ntry:\n with Pod(\n client=client,\n name=\"worker\",\n namespace=\"default\",\n containers=[{\"name\": \"app\", \"image\": \"myapp:latest\"}],\n schema_validation_enabled=True,\n ) as pod:\n pod.execute(command=[\"python\", \"run_task.py\"])\nexcept ValidationError as e:\n print(f\"Invalid resource spec: {e}\")\nexcept ExecOnPodError as e:\n print(f\"Task failed (rc={e.rc}): {e.err}\")\nexcept ResourceTeardownError as e:\n print(f\"Pod cleanup failed: {e}\")\nexcept MissingRequiredArgumentError as e:\n print(f\"Missing field: {e.argument}\")\n```\n\n### Safe Condition Waiting with Early Abort\n\n```python\nfrom ocp_resources.exceptions import ConditionError\nfrom timeout_sampler import TimeoutExpiredError\n\ntry:\n resource.wait_for_condition(\n condition=\"Ready\",\n status=\"True\",\n timeout=180,\n stop_condition=\"Failed\",\n stop_status=\"True\",\n )\nexcept ConditionError:\n # Resource entered a terminal failure state \u2014 no point waiting further\n resource.clean_up()\n raise\nexcept TimeoutExpiredError:\n # Condition not met within timeout \u2014 may be transient\n print(\"Resource is taking too long, investigating...\")\n```\n\n### Ignoring Non-Critical Command Failures\n\n```python\nfrom ocp_resources.exceptions import ExecOnPodError\n\n# Method 1: Use ignore_rc=True\noutput = pod.execute(command=[\"grep\", \"ERROR\", \"/var/log/app.log\"], ignore_rc=True)\n\n# Method 2: Catch and inspect the return code\ntry:\n output = pod.execute(command=[\"test\", \"-f\", \"/tmp/lockfile\"])\nexcept ExecOnPodError as e:\n if e.rc == 1:\n print(\"Lock file does not exist \u2014 proceeding\")\n else:\n raise # Re-raise unexpected errors\n```\n\n### Validation Before Batch Deployment\n\n```python\nfrom ocp_resources.exceptions import ValidationError\n\nresources = [pod1, pod2, deployment1, service1]\n\n# Pre-validate all resources before deploying any\nerrors = []\nfor resource in resources:\n try:\n resource.validate()\n except ValidationError as e:\n errors.append((resource.name, str(e)))\n\nif errors:\n for name, err in errors:\n print(f\" {name}: {err}\")\n raise SystemExit(\"Fix validation errors before deploying\")\n\nfor resource in resources:\n resource.deploy()\n```\n\n---\n\n## Exception Hierarchy Summary\n\n| Exception | Parent | Module |\n|-----------|--------|--------|\n| `ExecOnPodError` | `Exception` | `ocp_resources.exceptions` |\n| `MissingRequiredArgumentError` | `Exception` | `ocp_resources.exceptions` |\n| `ResourceTeardownError` | `Exception` | `ocp_resources.exceptions` |\n| `ValidationError` | `Exception` | `ocp_resources.exceptions` |\n| `ConditionError` | `Exception` | `ocp_resources.exceptions` |\n| `NNCPConfigurationFailed` | `Exception` | `ocp_resources.exceptions` |\n| `ClientWithBasicAuthError` | `Exception` | `ocp_resources.exceptions` |\n| `MissingResourceError` | `Exception` | `ocp_resources.exceptions` |\n| `MissingTemplateVariables` | `Exception` | `ocp_resources.exceptions` |\n\n> **Warning:** `MissingResourceResError` is deprecated and will be removed in a future release. Use `MissingResourceError` instead.\n\n## Related Pages\n\n- [Creating and Managing Resources](creating-and-managing-resources.html)\n- [Waiting for Resource Conditions and Status](waiting-for-conditions.html)\n- [Validating Resources Against OpenAPI Schemas](validating-resources.html)\n- [Executing Commands in Pods and Retrieving Logs](pod-execution-and-logs.html)\n- [Connecting to Clusters](connecting-to-clusters.html)\n"}] diff --git a/docs/testing-without-cluster.html b/docs/testing-without-cluster.html deleted file mode 100644 index cb89ed571a..0000000000 --- a/docs/testing-without-cluster.html +++ /dev/null @@ -1,904 +0,0 @@ - - - - - - Testing Without a Cluster Using the Fake Client - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Testing Without a Cluster Using the Fake Client

-

Write unit tests that create, read, update, and delete Kubernetes resources — including status checks, watch events, and custom CRDs — without connecting to a real cluster.

-

Prerequisites

- -

Quick Example

-
import pytest
-from ocp_resources.resource import get_client
-from ocp_resources.namespace import Namespace
-
-
-@pytest.fixture(scope="class")
-def fake_client():
-    return get_client(fake=True)
-
-
-class TestMyFeature:
-    def test_create_namespace(self, fake_client):
-        ns = Namespace(client=fake_client, name="my-namespace")
-        ns.deploy()
-
-        assert ns.exists
-        assert ns.kind == "Namespace"
-        assert ns.status == Namespace.Status.ACTIVE
-
-        ns.clean_up(wait=False)
-        assert not ns.exists
-
- -

Run it with pytest — no cluster, no kubeconfig, no network calls.

-

How It Works

-

Calling get_client(fake=True) returns an in-memory client that simulates the Kubernetes API. It supports all standard resources (Pods, Deployments, Services, Namespaces, etc.) and OpenShift resources (Routes, Projects, etc.) out of the box.

-

All resource classes (Pod, Deployment, Namespace, etc.) accept this fake client through their client parameter, and all CRUD methods work identically to a real cluster.

-

Step-by-Step: CRUD Operations

-

1. Set up a shared fixture

-

Create a conftest.py in your test directory:

-
import pytest
-from ocp_resources.resource import get_client
-
-
-@pytest.fixture(scope="class")
-def fake_client():
-    """Provides a fake Kubernetes client for all tests."""
-    return get_client(fake=True)
-
- -
-

Tip: Use scope="class" so resources created in one test method persist for subsequent methods in the same class.

-
-

2. Create a resource

-
from ocp_resources.pod import Pod
-
-
-def test_create_pod(fake_client):
-    pod = Pod(
-        client=fake_client,
-        name="my-pod",
-        namespace="default",
-        containers=[{"name": "nginx", "image": "nginx:latest"}],
-    )
-    result = pod.deploy()
-
-    assert result.name == "my-pod"
-    assert pod.exists
-
- -

The fake client automatically generates metadata (uid, resourceVersion, creationTimestamp) and a realistic status section for the resource.

-

3. Read and query resources

-
from ocp_resources.namespace import Namespace
-
-
-def test_list_resources(fake_client):
-    # Create some namespaces first
-    Namespace(client=fake_client, name="ns-a").deploy()
-    Namespace(client=fake_client, name="ns-b").deploy()
-
-    # Iterate over all Namespace resources
-    for ns in Namespace.get(client=fake_client):
-        assert ns.name
-
- -

Access individual resource data via the instance property:

-
def test_get_instance(fake_client):
-    ns = Namespace(client=fake_client, name="test-ns")
-    ns.deploy()
-
-    assert ns.instance
-    assert ns.kind == "Namespace"
-
- -

4. Update a resource

-

Use update() to patch a resource or update_replace() to replace it entirely:

-
def test_update_labels(fake_client):
-    ns = Namespace(client=fake_client, name="labeled-ns")
-    ns.deploy()
-
-    # Patch: add a label
-    resource_dict = ns.instance.to_dict()
-    resource_dict["metadata"]["labels"]["env"] = "staging"
-    ns.update(resource_dict=resource_dict)
-
-    assert ns.labels["env"] == "staging"
-
- -
def test_replace_labels(fake_client):
-    ns = Namespace(client=fake_client, name="replace-ns")
-    ns.deploy()
-
-    resource_dict = ns.instance.to_dict()
-    resource_dict["metadata"]["labels"] = {"new-label": "value"}
-    ns.update_replace(resource_dict=resource_dict)
-
-    assert "new-label" in ns.labels.keys()
-
- -

5. Delete a resource

-
def test_delete_resource(fake_client):
-    ns = Namespace(client=fake_client, name="temp-ns")
-    ns.deploy()
-    assert ns.exists
-
-    ns.clean_up(wait=False)
-    assert not ns.exists
-
- -

6. Use context managers for automatic cleanup

-

Resources support Python's with statement. The resource deploys on entry and is cleaned up on exit:

-
from ocp_resources.secret import Secret
-
-
-def test_context_manager(fake_client):
-    with Secret(name="my-secret", namespace="default", client=fake_client) as sec:
-        assert sec.exists
-
-    # Automatically cleaned up
-    assert not sec.exists
-
- -

Testing Status and Conditions

-

The fake client generates realistic status objects so you can test code that reads resource status.

-

Checking resource status

-
def test_namespace_status(fake_client):
-    ns = Namespace(client=fake_client, name="status-ns")
-    ns.deploy()
-
-    assert ns.status == Namespace.Status.ACTIVE
-
- -

Waiting for conditions

-
from ocp_resources.pod import Pod
-
-
-def test_wait_for_condition(fake_client):
-    pod = Pod(
-        client=fake_client,
-        name="ready-pod",
-        namespace="default",
-        containers=[{"name": "app", "image": "nginx:latest"}],
-    )
-    pod.deploy()
-
-    pod.wait_for_status(status=Pod.Status.RUNNING, timeout=5)
-
- -

Simulating "not ready" resources

-

Control a resource's readiness state with the fake-client.io/ready annotation:

-
def test_not_ready_pod(fake_client):
-    pod = Pod(
-        client=fake_client,
-        name="failing-pod",
-        namespace="default",
-        containers=[{"name": "app", "image": "nginx:latest"}],
-        annotations={"fake-client.io/ready": "false"},
-    )
-    pod.deploy()
-
-    # The pod will have Ready condition set to False
-    pod.wait_for_condition(
-        condition=pod.Condition.READY,
-        status=pod.Condition.Status.FALSE,
-        timeout=5,
-    )
-
-    message = pod.get_condition_message(
-        condition_type=pod.Condition.READY,
-        condition_status=pod.Condition.Status.FALSE,
-    )
-    assert message
-
- -

Resource-specific status behavior when marked "not ready":

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ResourceReady behaviorNot-ready behavior
Podphase: Running, containers readyContainers in waiting state
DeploymentreadyReplicas matches spec.replicasreadyReplicas: 0, unavailableReplicas set
Namespacephase: Activephase: Terminating
All othersReady condition TrueReady condition False
-

Testing with Events

-

The fake client automatically generates Kubernetes events for create, update, and delete operations. You can query them through the resource's events() method:

-
def test_resource_events(fake_client):
-    pod = Pod(
-        client=fake_client,
-        name="event-pod",
-        namespace="default",
-        containers=[{"name": "nginx", "image": "nginx:latest"}],
-    )
-    pod.deploy()
-
-    events = list(pod.events(timeout=1))
-    assert events
-
- -

Testing with Deployments

-

Deployments get a full status template including replica counts, conditions, and observed generation:

-
from ocp_resources.deployment import Deployment
-
-
-def test_deployment(fake_client):
-    dep = Deployment(
-        client=fake_client,
-        name="my-deploy",
-        namespace="default",
-        replicas=3,
-        selector={"matchLabels": {"app": "web"}},
-        template={
-            "metadata": {"labels": {"app": "web"}},
-            "spec": {"containers": [{"name": "web", "image": "nginx:latest"}]},
-        },
-    )
-    dep.deploy()
-
-    assert dep.exists
-    assert dep.kind == "Deployment"
-
-    dep.clean_up(wait=False)
-    assert not dep.exists
-
- -

Testing with ResourceList

-

Test bulk resource creation with ResourceList and NamespacedResourceList:

-
from ocp_resources.resource import ResourceList, NamespacedResourceList
-
-
-def test_resource_list(fake_client):
-    with ResourceList(
-        client=fake_client,
-        resource_class=Namespace,
-        name="batch-ns",
-        num_resources=3,
-    ) as namespaces:
-        assert len(namespaces) == 3
-    # All three namespaces are automatically cleaned up
-
- -

See Managing Bulk Resources with ResourceList for more details.

-

Advanced Usage

-

Using the low-level API directly

-

You can bypass resource classes and work with the fake client's API directly — useful for testing custom logic:

-
def test_low_level_api(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Pod")
-
-    # Create
-    pod = api.create(
-        body={
-            "apiVersion": "v1",
-            "kind": "Pod",
-            "metadata": {"name": "raw-pod", "namespace": "default"},
-            "spec": {"containers": [{"name": "app", "image": "nginx:latest"}]},
-        },
-        namespace="default",
-    )
-    assert pod.metadata.name == "raw-pod"
-    assert pod.status.phase == "Running"
-
-    # List
-    pods = api.get(namespace="default")
-    assert len(pods.items) >= 1
-
-    # Patch
-    updated = api.patch(
-        name="raw-pod",
-        namespace="default",
-        body={"metadata": {"labels": {"env": "test"}}},
-    )
-    assert updated.metadata.labels["env"] == "test"
-
-    # Delete
-    api.delete(name="raw-pod", namespace="default")
-
- -

Label and field selectors

-

The fake client supports filtering resources by labels and fields:

-
def test_label_selector(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Pod")
-
-    api.create(
-        body={
-            "apiVersion": "v1",
-            "kind": "Pod",
-            "metadata": {"name": "pod-a", "namespace": "default", "labels": {"app": "web"}},
-            "spec": {"containers": [{"name": "c", "image": "nginx"}]},
-        },
-        namespace="default",
-    )
-
-    api.create(
-        body={
-            "apiVersion": "v1",
-            "kind": "Pod",
-            "metadata": {"name": "pod-b", "namespace": "default", "labels": {"app": "api"}},
-            "spec": {"containers": [{"name": "c", "image": "nginx"}]},
-        },
-        namespace="default",
-    )
-
-    # Filter by label
-    web_pods = api.get(namespace="default", label_selector="app=web")
-    assert len(web_pods.items) == 1
-    assert web_pods.items[0].metadata.name == "pod-a"
-
- -

Field selectors support metadata.name and metadata.namespace:

-
def test_field_selector(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Pod")
-    result = api.get(namespace="default", field_selector="metadata.name=my-pod")
-
- -

Watch for resource changes

-

The watch() method yields existing resources as ADDED events:

-
def test_watch(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Pod")
-
-    api.create(
-        body={
-            "apiVersion": "v1",
-            "kind": "Pod",
-            "metadata": {"name": "watch-pod", "namespace": "default"},
-            "spec": {"containers": [{"name": "c", "image": "nginx"}]},
-        },
-        namespace="default",
-    )
-
-    for event in api.watch(namespace="default"):
-        assert event["type"] == "ADDED"
-        assert event["object"].metadata.name
-        break  # Stop after first event
-
- -

Registering Custom Resource Definitions

-

To test with CRDs not bundled in the default schema, register them on the client:

-
def test_custom_resource(fake_client):
-    # Register the CRD
-    fake_client.register_resources({
-        "kind": "MyApp",
-        "api_version": "v1alpha1",
-        "group": "apps.example.com",
-        "namespaced": True,
-    })
-
-    # Use it through the low-level API
-    api = fake_client.resources.get(
-        api_version="apps.example.com/v1alpha1",
-        kind="MyApp",
-    )
-
-    created = api.create(
-        body={
-            "apiVersion": "apps.example.com/v1alpha1",
-            "kind": "MyApp",
-            "metadata": {"name": "my-app", "namespace": "default"},
-            "spec": {"replicas": 2},
-        },
-        namespace="default",
-    )
-    assert created.metadata.name == "my-app"
-
- -

You can also register multiple CRDs at once:

-
fake_client.register_resources([
-    {"kind": "MyApp", "api_version": "v1", "group": "apps.example.com"},
-    {"kind": "MyConfig", "api_version": "v1beta1", "group": "config.example.com", "namespaced": False},
-])
-
- -

Testing incremental workflows

-

Use @pytest.mark.incremental to test a resource through its full lifecycle. Each test depends on the previous one succeeding:

-
import pytest
-from ocp_resources.pod import Pod
-
-
-@pytest.mark.incremental
-class TestPodLifecycle:
-    @pytest.fixture(scope="class")
-    def pod(self, fake_client):
-        return Pod(
-            client=fake_client,
-            name="lifecycle-pod",
-            namespace="default",
-            containers=[{"name": "nginx", "image": "nginx:latest"}],
-        )
-
-    def test_01_create(self, pod):
-        deployed = pod.deploy()
-        assert deployed.name == "lifecycle-pod"
-        assert pod.exists
-
-    def test_02_read(self, pod):
-        assert pod.instance
-        assert pod.kind == "Pod"
-
-    def test_03_update(self, pod):
-        resource_dict = pod.instance.to_dict()
-        resource_dict["metadata"]["labels"] = {"updated": "true"}
-        pod.update(resource_dict=resource_dict)
-        assert pod.labels["updated"] == "true"
-
-    def test_04_delete(self, pod):
-        pod.clean_up(wait=False)
-        assert not pod.exists
-
- -
-

Note: The @pytest.mark.incremental marker requires custom conftest.py hooks. Add the following to your conftest.py: -```python -def pytest_runtest_makereport(item, call): - if call.excinfo is not None and "incremental" in item.keywords: - parent = item.parent - parent._previousfailed = item -

-

def pytest_runtest_setup(item): - if "incremental" in item.keywords: - previousfailed = getattr(item.parent, "_previousfailed", None) - if previousfailed is not None: - pytest.xfail(f"previous test failed ({previousfailed.name})") -```

-
-

Conflict detection

-

The fake client raises errors for duplicate creates and missing resources, just like a real cluster:

-
from fake_kubernetes_client import NotFoundError, ConflictError
-
-
-def test_conflict_on_duplicate_create(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Namespace")
-
-    api.create(body={"metadata": {"name": "unique-ns"}})
-
-    with pytest.raises(ConflictError):
-        api.create(body={"metadata": {"name": "unique-ns"}})
-
-
-def test_not_found_on_missing(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Pod")
-
-    with pytest.raises(NotFoundError):
-        api.get(name="does-not-exist", namespace="default")
-
- -

Accessing response data

-

The fake client returns response objects that support both attribute access and dictionary access:

-
def test_response_access(fake_client):
-    api = fake_client.resources.get(api_version="v1", kind="Pod")
-
-    created = api.create(
-        body={
-            "apiVersion": "v1",
-            "kind": "Pod",
-            "metadata": {"name": "access-pod", "namespace": "default"},
-            "spec": {"containers": [{"name": "c", "image": "nginx"}]},
-        },
-        namespace="default",
-    )
-
-    # Attribute access
-    assert created.metadata.name == "access-pod"
-    assert created.status.phase == "Running"
-
-    # Dictionary-style access
-    assert created["metadata"]["name"] == "access-pod"
-
-    # Convert to a plain dict
-    data = created.to_dict()
-    assert isinstance(data, dict)
-    assert data["metadata"]["name"] == "access-pod"
-
- -

Troubleshooting

-

ResourceNotFoundError when creating a resource

-

The fake client must recognize the resource's API group and kind. Standard Kubernetes and OpenShift resources are pre-registered. For CRDs, call fake_client.register_resources(...) before using them.

-

Status fields are missing or empty

-

Pods, Deployments, Services, and Namespaces have hardcoded status templates. Other resources get a generic status with a Ready condition. If the resource has a schema in the bundled OpenAPI definitions, a richer status is generated automatically.

-

Tests interfere with each other

-

Each call to get_client(fake=True) creates an independent in-memory store. Use separate fixtures or narrower fixture scopes to isolate tests that share a client.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/validating-resources.html b/docs/validating-resources.html deleted file mode 100644 index ac60120d43..0000000000 --- a/docs/validating-resources.html +++ /dev/null @@ -1,634 +0,0 @@ - - - - - - Validating Resources Against OpenAPI Schemas - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Validating Resources Against OpenAPI Schemas

-

Catch configuration mistakes before they reach the Kubernetes API by validating resource definitions against OpenAPI schemas. This saves debugging time and prevents failed deployments caused by typos, missing fields, or incorrect types.

-

Prerequisites

- -

Quick Example

-
from ocp_resources.pod import Pod
-
-pod = Pod(
-    name="nginx-pod",
-    namespace="default",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-)
-
-# Validate against the OpenAPI schema — raises ValidationError if invalid
-pod.validate()
-
- -

If the resource is valid, validate() returns silently. If something is wrong, it raises a ValidationError with a clear message pointing to the problematic field.

-

Manual Validation

-

Call .validate() on any resource instance to check it against the OpenAPI schema for its kind:

-
from ocp_resources.exceptions import ValidationError
-from ocp_resources.service import Service
-
-service = Service(
-    name="nginx-service",
-    namespace="default",
-    selector={"app": "nginx"},
-    ports=[{"port": 80, "targetPort": 80, "protocol": "TCP"}],
-)
-
-try:
-    service.validate()
-    print("Service configuration is valid")
-except ValidationError as e:
-    print(f"Validation failed: {e}")
-
- -
-

Tip: Use manual validation in CI pipelines or pre-commit hooks to catch errors before applying resources to a cluster.

-
-

Validating Dictionaries Without Creating Objects

-

Use the validate_dict() class method to validate a raw resource dictionary without instantiating a resource object:

-
from ocp_resources.deployment import Deployment
-from ocp_resources.exceptions import ValidationError
-
-deployment_dict = {
-    "apiVersion": "apps/v1",
-    "kind": "Deployment",
-    "metadata": {"name": "nginx-deployment", "namespace": "default"},
-    "spec": {
-        "replicas": 3,
-        "selector": {"matchLabels": {"app": "nginx"}},
-        "template": {
-            "metadata": {"labels": {"app": "nginx"}},
-            "spec": {
-                "containers": [{"name": "nginx", "image": "nginx:1.21", "ports": [{"containerPort": 80}]}],
-            },
-        },
-    },
-}
-
-try:
-    Deployment.validate_dict(resource_dict=deployment_dict)
-    print("Deployment configuration is valid")
-except ValidationError as e:
-    print(f"Invalid: {e}")
-
- -

This is useful when you have a dictionary from a YAML file or external source and want to validate it before creating the resource.

-

Enabling Auto-Validation on Create

-

Set schema_validation_enabled=True when creating a resource instance to automatically validate before every create(), deploy(), or update_replace() call:

-
from ocp_resources.pod import Pod
-from ocp_resources.exceptions import ValidationError
-
-pod = Pod(
-    client=client,
-    name="auto-validated-pod",
-    namespace="default",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-    schema_validation_enabled=True,
-)
-
-# Validation runs automatically before the resource is sent to the API
-pod.deploy()
-
- -

If validation fails, the resource is not created and a ValidationError is raised:

-
invalid_pod = Pod(
-    client=client,
-    name="bad-pod",
-    namespace="default",
-    containers=[{"name": "nginx"}],  # Missing required 'image' field
-    schema_validation_enabled=True,
-)
-
-try:
-    invalid_pod.deploy()
-except ValidationError as e:
-    print(f"Blocked: {e}")
-
- -
-

Note: Auto-validation is disabled by default (schema_validation_enabled=False). You must opt in per instance.

-
-

When Auto-Validation Runs

-
- - - - - - - - - - - - - - - - - - - - - - - - -
OperationAuto-validates?Notes
create() / deploy()✅ YesValidates the full resource before sending
update_replace()✅ YesValidates the replacement dictionary
update()❌ NoSends a partial patch, not a full resource
-

Auto-validation also applies when using context managers, since they call deploy() internally. See Creating and Managing Resources for details on resource lifecycle methods.

-

Toggling Validation at Runtime

-

You can enable or disable validation on an existing instance:

-
pod = Pod(
-    client=client,
-    name="my-pod",
-    namespace="default",
-    containers=[{"name": "nginx", "image": "nginx:latest"}],
-)
-
-# Enable after creation
-pod.schema_validation_enabled = True
-pod.deploy()  # Will validate first
-
-# Disable for a subsequent operation
-pod.schema_validation_enabled = False
-
- -

Advanced Usage

-

Validating Resources with Duplicate API Groups

-

Some resource kinds (like DNS) exist in multiple API groups. The validator uses the resource's api_group to select the correct schema automatically:

-
# These two resources share the kind "DNS" but use different API groups.
-# Each is validated against its own schema.
-dns_config.validate()  # Uses config.openshift.io schema
-dns_operator.validate()  # Uses operator.openshift.io schema
-
- -

See Common Resource Patterns for more on working with resources that share a kind name.

-

Pre-Warming the Schema Cache

-

The first validation for a resource kind loads and resolves the schema (~25ms). Subsequent validations for the same kind use a cache and are much faster (~2ms). Pre-warm the cache at application startup for critical resource types:

-
from ocp_resources.pod import Pod
-from ocp_resources.deployment import Deployment
-from ocp_resources.service import Service
-from ocp_resources.exceptions import ValidationError
-
-for resource_cls in [Pod, Deployment, Service]:
-    try:
-        resource_cls.validate_dict({"apiVersion": "v1", "kind": resource_cls.kind, "metadata": {"name": "warmup"}})
-    except ValidationError:
-        pass  # Expected — we just want to load the schemas
-
- -

Disabling Validation for Bulk Operations

-

When creating many resources in a loop, disable auto-validation to avoid repeated checks if you've already validated your templates:

-
# Validate the template once
-Pod.validate_dict(resource_dict=pod_template)
-
-# Then create many instances without per-instance validation
-for i in range(100):
-    pod = Pod(
-        client=client,
-        name=f"worker-{i}",
-        namespace="default",
-        containers=[{"name": "app", "image": "myapp:latest"}],
-        schema_validation_enabled=False,  # Skip — already validated
-    )
-    pod.deploy()
-
- -

Troubleshooting

-

Reading Validation Error Messages

-

Validation errors include three key pieces of information:

-
Resource validation failed for Pod/my-pod
-  Field: spec.containers[0].image
-  Error: 123 is not of type 'string'
-  Expected type: string
-
- -
    -
  • Resource identifier — which resource failed (kind and name)
  • -
  • Field path — the JSON path to the problematic field
  • -
  • Error details — what went wrong and what was expected
  • -
-

Common Validation Error Patterns

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Error message patternCauseFix
'X' is a required propertyMissing a required fieldAdd the missing field to your resource definition
'X' is not of type 'Y'Wrong data typeUse the correct type (e.g., 3 not "3")
'X' does not match patternInvalid formatFollow Kubernetes naming rules (lowercase, alphanumeric, hyphens)
'X' is not one of [...]Invalid enum valueUse one of the allowed values listed in the error
Additional properties are not allowedUnknown field nameCheck for typos in field names
-

Validation Passes But API Rejects the Resource

-

OpenAPI schema validation catches structural issues (wrong types, missing fields, invalid formats) but cannot check everything the API server validates. For example:

-
    -
  • Unique name constraints within a namespace
  • -
  • Resource quota limits
  • -
  • Admission webhook rules
  • -
  • Cross-resource references (e.g., a ConfigMap that doesn't exist)
  • -
-

If validation passes but the API still rejects your resource, the issue is likely a server-side constraint.

-

No Schema Available for a Resource

-

If no schema is bundled for a resource kind, validate() silently succeeds instead of raising an error. A debug-level log message is emitted:

-
No schema found for MyCustomResource, skipping validation
-
- -

To update bundled schemas, see class-generator CLI Reference.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/waiting-for-conditions.html b/docs/waiting-for-conditions.html deleted file mode 100644 index ade473a921..0000000000 --- a/docs/waiting-for-conditions.html +++ /dev/null @@ -1,851 +0,0 @@ - - - - - - Waiting for Resource Conditions and Status - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Waiting for Resource Conditions and Status

-

Wait for Kubernetes and OpenShift resources to reach a desired status, condition, or lifecycle state using built-in polling methods with configurable timeouts and early exit on failure.

-

Prerequisites

- -

Quick Example

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-pod.create(wait=True)  # Creates the pod and waits for it to exist
-
-# Wait for the pod to reach "Running" status
-pod.wait_for_status(status=Pod.Status.RUNNING)
-
- -

Waiting for a Resource to Exist

-

The wait() method polls until a resource exists on the cluster. This is useful after creating a resource or when waiting for an external controller to produce one.

-
from ocp_resources.namespace import Namespace
-
-ns = Namespace(client=client, name="my-namespace")
-ns.create()
-ns.wait(timeout=120, sleep=2)
-
- -
- - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
timeoutint240 (4 minutes)Maximum seconds to wait
sleepint1Seconds between polls
-

Raises: TimeoutExpiredError if the resource does not appear before the timeout.

-
-

Tip: Pass wait=True to create() or deploy() to combine creation and existence waiting in one call: -```python -pod.create(wait=True)

-

or

-

pod.deploy(wait=True) -```

-
-

Waiting for Deletion

-

The wait_deleted() method polls until a resource no longer exists:

-
pod.delete(wait=True, timeout=120)
-
-# Or call wait_deleted() separately:
-pod.delete()
-success = pod.wait_deleted(timeout=120)
-if not success:
-    print("Resource still exists after timeout")
-
- -
- - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
timeoutint240 (4 minutes)Maximum seconds to wait
-

Returns: True if the resource was deleted, False if the timeout was reached.

-
-

Note: Unlike most wait methods, wait_deleted() returns False on timeout rather than raising an exception.

-
-

Waiting for Status (Phase)

-

The wait_for_status() method polls status.phase until it matches the expected value:

-
from ocp_resources.datavolume import DataVolume
-
-dv = DataVolume(client=client, name="my-dv", namespace="default")
-dv.wait_for_status(status=DataVolume.Status.SUCCEEDED, timeout=600)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
statusstr(required)Expected status.phase value
timeoutint240 (4 minutes)Maximum seconds to wait
stop_statusstr \| NoneStatus.FAILEDStatus that aborts the wait immediately
sleepint1Seconds between polls
exceptions_dictdictProtocol + cluster retry errorsExceptions to catch and retry
-

Raises: TimeoutExpiredError if the resource does not reach the desired status, or if stop_status is encountered.

-

Using stop_status for early failure

-

By default, wait_for_status() aborts immediately if the resource enters a "Failed" phase. Override this to detect a different failure phase:

-
from ocp_resources.virtual_machine_instance import VirtualMachineInstance
-
-vmi = VirtualMachineInstance(client=client, name="my-vmi", namespace="default")
-vmi.wait_for_status(
-    status=VirtualMachineInstance.Status.RUNNING,
-    stop_status="ErrorUnschedulable",
-    timeout=300,
-)
-
- -

Built-in status constants

-

Every resource inherits status constants from its base class. Resource-specific subclasses extend these with additional values:

-
# Base status constants (available on all resources)
-from ocp_resources.resource import Resource
-
-Resource.Status.SUCCEEDED  # "Succeeded"
-Resource.Status.FAILED  # "Failed"
-Resource.Status.RUNNING  # "Running"
-Resource.Status.PENDING  # "Pending"
-
-# Resource-specific statuses
-from ocp_resources.virtual_machine import VirtualMachine
-
-VirtualMachine.Status.MIGRATING  # "Migrating"
-VirtualMachine.Status.STOPPED  # "Stopped"
-VirtualMachine.Status.STARTING  # "Starting"
-VirtualMachine.Status.PAUSED  # "Paused"
-
-from ocp_resources.persistent_volume_claim import PersistentVolumeClaim
-
-PersistentVolumeClaim.Status.BOUND  # "Bound"
-
- -

Waiting for Conditions

-

The wait_for_condition() method waits for a specific entry in status.conditions[] to match a desired type, status, and optionally a reason or message:

-
from ocp_resources.user_defined_network import UserDefinedNetwork
-
-udn = UserDefinedNetwork(client=client, name="my-net", namespace="default")
-udn.wait_for_condition(
-    condition=UserDefinedNetwork.Condition.NETWORK_READY,
-    status=UserDefinedNetwork.Condition.Status.TRUE,
-    timeout=30,
-)
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
conditionstr(required)Condition type to match (e.g. "Ready", "Available")
statusstr(required)Expected condition status ("True", "False", "Unknown")
timeoutint300 (5 minutes)Maximum seconds to wait
sleep_timeint1Seconds between polls
reasonstr \| NoneNoneIf set, the condition's reason field must also match
messagestr""Text that must appear in the condition's message field
stop_conditionstr \| NoneNoneA condition type that aborts the wait immediately
stop_statusstr"True"The status of the stop condition that triggers early abort
-

Raises:

-
    -
  • TimeoutExpiredError — if the condition is not met before the timeout
  • -
  • ConditionError — if the stop_condition is detected before the desired condition
  • -
-

Matching by reason and message

-
pod.wait_for_condition(
-    condition="Ready",
-    status="True",
-    reason="PodCompleted",
-    message="containers with",
-    timeout=120,
-)
-
- -

The reason must be an exact match. The message is checked using substring inclusion — the condition's message field must contain the provided string.

-

Using stop_condition for early abort

-

Stop conditions let you fail fast when an unrecoverable condition appears:

-
from ocp_resources.exceptions import ConditionError
-
-try:
-    resource.wait_for_condition(
-        condition="Ready",
-        status="True",
-        stop_condition="Failed",
-        stop_status="True",
-        timeout=300,
-    )
-except ConditionError as e:
-    print(f"Resource hit a failure condition: {e}")
-
- -
-

Note: The stop_condition matching only checks the condition type and status. The reason and message parameters are ignored when evaluating stop conditions.

-
-

Built-in condition constants

-
from ocp_resources.resource import Resource
-
-# Condition types
-Resource.Condition.READY  # "Ready"
-Resource.Condition.AVAILABLE  # "Available"
-Resource.Condition.DEGRADED  # "Degraded"
-Resource.Condition.PROGRESSING  # "Progressing"
-Resource.Condition.UPGRADEABLE  # "Upgradeable"
-Resource.Condition.NETWORK_READY  # "NetworkReady"
-
-# Condition statuses
-Resource.Condition.Status.TRUE  # "True"
-Resource.Condition.Status.FALSE  # "False"
-Resource.Condition.Status.UNKNOWN  # "Unknown"
-
-# Condition reasons
-Resource.Condition.Reason.ALL_REQUIREMENTS_MET  # "AllRequirementsMet"
-Resource.Condition.Reason.INSTALL_SUCCEEDED  # "InstallSucceeded"
-
- -

Waiting with Context Managers

-

When using a resource as a context manager, set wait_for_resource=True to automatically wait for the resource to exist after creation:

-
from ocp_resources.pod import Pod
-
-with Pod(
-    client=client,
-    name="my-pod",
-    namespace="default",
-    wait_for_resource=True,
-) as pod:
-    # Pod exists when you reach this line
-    pod.wait_for_status(status=Pod.Status.RUNNING)
-
- -

See Creating and Managing Resources for more on context manager usage.

-

Advanced Usage

-

Waiting for any conditions to appear

-

The wait_for_conditions() method waits up to 30 seconds for the resource's status.conditions list to be populated (non-empty). This is useful when you need to inspect conditions but aren't sure they've been set yet:

-
resource.wait_for_conditions()
-# Now resource.instance.status.conditions is available
-
- -

Resource-specific wait methods

-

Many resource types provide specialized wait methods beyond the base wait_for_status() and wait_for_condition(). These methods encapsulate domain-specific logic.

-

Deployments

-
from ocp_resources.deployment import Deployment
-
-deploy = Deployment(client=client, name="my-app", namespace="default")
-deploy.wait_for_replicas(deployed=True, timeout=240)  # All replicas ready
-deploy.wait_for_replicas(deployed=False, timeout=120)  # Scale to zero complete
-
- -

DaemonSets

-
from ocp_resources.daemonset import DaemonSet
-
-ds = DaemonSet(client=client, name="my-ds", namespace="default")
-ds.wait_until_deployed(timeout=240)  # All desired pods are ready
-
- -

VirtualMachines (KubeVirt)

-
from ocp_resources.virtual_machine import VirtualMachine
-from ocp_resources.virtual_machine_instance import VirtualMachineInstance
-
-vm = VirtualMachine(client=client, name="my-vm", namespace="default")
-
-# Wait for VM ready status (True = running, None = stopped)
-vm.wait_for_ready_status(status=True, timeout=240)
-
-# Wait for a specific status field to become None
-vm.wait_for_status_none(status="snapshotInProgress", timeout=240)
-
-# Wait for VMI to be running
-vmi = VirtualMachineInstance(client=client, name="my-vm", namespace="default")
-vmi.wait_until_running(timeout=240, logs=True)
-
-# Wait for VMI pause state
-vmi.wait_for_pause_status(pause=True, timeout=240)
-
- -

See Working with Virtual Machines (KubeVirt) for the full VM lifecycle.

-

DataVolumes

-
from ocp_resources.datavolume import DataVolume
-
-dv = DataVolume(client=client, name="my-dv", namespace="default")
-dv.wait_for_dv_success(
-    timeout=600,
-    failure_timeout=120,
-    pvc_wait_for_bound_timeout=60,
-)
-
- -

The wait_for_dv_success() method handles the full DataVolume lifecycle: it waits for the status to leave Pending, then waits for Succeeded, and finally confirms the PVC is Bound.

-

You can pass a stop_status_func callback to abort early on custom conditions:

-
def dv_is_stuck(dv):
-    return dv.instance.status.conditions.restartCount > 3
-
-
-dv.wait_for_dv_success(
-    stop_status_func=dv_is_stuck,
-    dv=dv,  # passed as keyword argument to the function
-)
-
- -

VirtualMachineSnapshot / VirtualMachineRestore

-
from ocp_resources.virtual_machine_snapshot import VirtualMachineSnapshot
-from ocp_resources.virtual_machine_restore import VirtualMachineRestore
-
-snapshot = VirtualMachineSnapshot(client=client, name="snap-1", namespace="default")
-snapshot.wait_snapshot_done(timeout=240)  # readyToUse + VM snapshotInProgress is None
-
-restore = VirtualMachineRestore(client=client, name="restore-1", namespace="default")
-restore.wait_restore_done(timeout=240)  # complete + VM restoreInProgress is None
-
- -

MachineSet

-
from ocp_resources.machine_set import MachineSet
-
-ms = MachineSet(client=client, name="worker-set", namespace="openshift-machine-api")
-ms.wait_for_replicas(timeout=300, sleep=1)
-
- -

Returns True when ready replicas match desired replicas, False on timeout.

-

NodeNetworkConfigurationPolicy

-
from ocp_resources.node_network_configuration_policy import NodeNetworkConfigurationPolicy
-
-nncp = NodeNetworkConfigurationPolicy(client=client, name="br1-policy")
-nncp.wait_for_status_success()  # Uses the resource's own success_timeout
-
- -

Raises NNCPConfigurationFailed if the policy fails or finds no matching nodes.

-

Timeout constants

-

The library provides predefined timeout constants you can use with any wait method:

-
from ocp_resources.utils.constants import (
-    TIMEOUT_1SEC,  # 1
-    TIMEOUT_5SEC,  # 5
-    TIMEOUT_10SEC,  # 10
-    TIMEOUT_30SEC,  # 30
-    TIMEOUT_1MINUTE,  # 60
-    TIMEOUT_2MINUTES,  # 120
-    TIMEOUT_4MINUTES,  # 240
-    TIMEOUT_10MINUTES,  # 600
-)
-
-pod.wait_for_status(status=Pod.Status.RUNNING, timeout=TIMEOUT_10MINUTES)
-
- -

Custom exception retries

-

Wait methods handle transient cluster errors automatically (connection resets, etcd leader changes, protocol errors). You can customize which exceptions are retried:

-
from ocp_resources.utils.constants import (
-    PROTOCOL_ERROR_EXCEPTION_DICT,
-    DEFAULT_CLUSTER_RETRY_EXCEPTIONS,
-)
-
-# Combine default retry exceptions
-pod.wait_for_status(
-    status=Pod.Status.RUNNING,
-    exceptions_dict={
-        **PROTOCOL_ERROR_EXCEPTION_DICT,
-        **DEFAULT_CLUSTER_RETRY_EXCEPTIONS,
-    },
-)
-
- -

The default retry exceptions cover:

-
    -
  • ProtocolError — network-level failures
  • -
  • MaxRetryError, ConnectionAbortedError, ConnectionResetError — connection failures
  • -
  • InternalServerError — etcd leader changes, webhook failures, RPC errors
  • -
  • ServerTimeoutError — API server timeouts
  • -
  • ForbiddenError — context deadline exceeded errors
  • -
-

Troubleshooting

-

TimeoutExpiredError is raised unexpectedly

-
    -
  • Increase the timeout. The default is 240 seconds (4 minutes), which may not be enough for slow operations like DataVolume imports or VM migrations.
  • -
  • Check stop_status. wait_for_status() defaults stop_status to Status.FAILED. If your resource legitimately passes through a "Failed" phase, set stop_status to a value that won't match, or set it to None.
  • -
  • Inspect the current status. The error log includes the last observed status. Read it to understand where the resource got stuck.
  • -
-

ConditionError on wait_for_condition()

-

This means the stop_condition was matched before the desired condition. The error message includes the full condition details. Review your stop_condition and stop_status settings.

-

Resource exists but wait_for_status() times out

-

The wait_for_status() method reads from status.phase. Some resources use different status fields (e.g., status.ready, status.readyToUse, status.syncStatus). For those resources, use the resource-specific wait methods documented above, or use wait_for_condition() to match on status.conditions[] entries instead.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/working-with-events.html b/docs/working-with-events.html deleted file mode 100644 index 4036709985..0000000000 --- a/docs/working-with-events.html +++ /dev/null @@ -1,899 +0,0 @@ - - - - - - Working with Kubernetes Events - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Working with Kubernetes Events

-

Monitor, retrieve, and clean up Kubernetes events to debug resource behavior and automate event-driven workflows using the Event class and the resource.events() method.

-

Prerequisites

- -

Quick Example

-

Stream events from a namespace in real-time:

-
from ocp_resources.event import Event
-
-for event in Event.get(client=client, namespace="my-namespace", timeout=30):
-    print(f"{event['type']}: {event.object.reason} — {event.object.message}")
-
- -

Streaming Events in Real-Time with Event.get()

-

Event.get() opens a watch connection and yields events as they occur. It's a generator that blocks until the timeout expires or you break out of the loop.

-
from ocp_resources.event import Event
-
-for event in Event.get(
-    client=client,
-    namespace="my-namespace",
-    timeout=60,
-):
-    obj = event.object
-    print(f"[{obj.type}] {obj.reason}: {obj.message}")
-
- -

Each yielded event is a watch event dictionary with these keys:

-
- - - - - - - - - - - - - - - - - - - - -
KeyDescription
typeWatch event type: ADDED, MODIFIED, or DELETED
objectThe Event resource object (access .type, .reason, .message, .involvedObject, etc.)
raw_objectThe raw dictionary representation of the event
-

Event.get() Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClientNoneKubernetes dynamic client (required)
namespacestr \| NoneNoneFilter events to a specific namespace
namestr \| NoneNoneFilter by event name
label_selectorstr \| NoneNoneFilter by labels (e.g. "app=nginx")
field_selectorstr \| NoneNoneFilter by fields (e.g. "type==Warning")
resource_versionstr \| NoneNoneStart watching from a specific resource version
timeoutint \| NoneNoneTimeout in seconds; None watches indefinitely
-

Returns: Generator — yields watch event dictionaries.

-

Filtering with Field Selectors

-

Field selectors let you narrow down events to exactly what you care about. Combine multiple selectors with commas:

-
# Only Warning events for ClusterServiceVersion resources
-for event in Event.get(
-    client=client,
-    namespace="my-namespace",
-    field_selector="involvedObject.kind==ClusterServiceVersion,type==Warning,reason==AnEventReason",
-    timeout=10,
-):
-    print(event.object.message)
-
- -

Common field selector fields:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldExample
typetype==Warning or type==Normal
reasonreason==FailedScheduling
involvedObject.kindinvolvedObject.kind==Pod
involvedObject.nameinvolvedObject.name==my-pod
involvedObject.namespaceinvolvedObject.namespace==default
-

Listing Existing Events with Event.list()

-

Unlike Event.get(), which streams events in real-time, Event.list() returns existing events immediately as a list. By default it only returns events from the last 5 minutes.

-
from ocp_resources.event import Event
-
-# List all Warning events from the last 5 minutes
-events = Event.list(
-    client=client,
-    namespace="my-namespace",
-    field_selector="type==Warning",
-)
-
-for event in events:
-    print(f"{event.reason}: {event.message}")
-
- -

Results are sorted by lastTimestamp descending (most recent first).

-

Event.list() Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClient—Kubernetes dynamic client (required)
namespacestr \| NoneNoneFilter events to a specific namespace
field_selectorstr \| NoneNoneFilter by fields (e.g. "type==Warning")
label_selectorstr \| NoneNoneFilter by labels
since_secondsint300Only return events from the last N seconds
-

Returns: list — event resource objects sorted by timestamp (most recent first).

-

Raises: ValueError — if since_seconds is negative.

-
# Events from the last 30 minutes
-events = Event.list(client=client, since_seconds=1800)
-
-# All events (no time filter — uses a very large window)
-events = Event.list(client=client, since_seconds=999999)
-
- -

When to Use Event.get() vs Event.list()

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Use caseMethod
Watch for new events as they happenEvent.get()
Fetch events that already occurredEvent.list()
Collect events during a test runEvent.get()
Check recent events after a failureEvent.list()
Stream events with a timeoutEvent.get()
Get a snapshot sorted by timeEvent.list()
-

Getting Events for a Specific Resource

-

Every resource instance has an .events() method that automatically filters events to that specific resource by setting involvedObject.name in the field selector.

-
from ocp_resources.pod import Pod
-
-pod = Pod(client=client, name="my-pod", namespace="default")
-
-for event in pod.events(timeout=10):
-    print(f"{event.object.reason}: {event.object.message}")
-
- -

You can add extra filters on top of the automatic resource filter:

-
# Only Warning events for this specific pod
-for event in pod.events(
-    field_selector="type==Warning",
-    timeout=10,
-):
-    print(event.object.message)
-
- -

resource.events() Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
namestr""Filter by event name
label_selectorstr""Filter by labels
field_selectorstr""Additional field selectors (combined with involvedObject.name automatically)
resource_versionstr""Start watching from a specific resource version
timeoutint240Timeout in seconds (default: 4 minutes)
-

Returns: Generator — yields watch event dictionaries, same format as Event.get().

-
-

Note: The field_selector you provide is appended to the automatic involvedObject.name==<resource-name> filter. You don't need to specify the resource name yourself.

-
-

Deleting Events with Event.delete_events()

-

Clean up events before a test run to avoid false positives from stale events:

-
from ocp_resources.event import Event
-
-# Delete all events in a namespace
-Event.delete_events(client=client, namespace="my-namespace")
-
-# Delete events matching a specific reason
-Event.delete_events(
-    client=client,
-    namespace="my-namespace",
-    field_selector="reason==AnEventReason",
-)
-
- -

Event.delete_events() Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
clientDynamicClientNoneKubernetes dynamic client (required)
namespacestr \| NoneNoneTarget namespace
namestr \| NoneNoneSpecific event name to delete
label_selectorstr \| NoneNoneFilter by labels
field_selectorstr \| NoneNoneFilter by fields
resource_versionstr \| NoneNoneFilter by resource version
timeoutint \| NoneNoneTimeout in seconds
-

Returns: None

-

Advanced Usage

-

Test Setup: Clean Events Before Each Test

-

A common pattern is deleting events before a test so that only events generated during the test are captured:

-
from ocp_resources.event import Event
-
-
-def test_pod_scheduling(client, namespace):
-    # Clean slate — remove old events
-    Event.delete_events(client=client, namespace=namespace)
-
-    # ... create resources and trigger the behavior under test ...
-
-    # Verify expected events occurred
-    events = Event.list(
-        client=client,
-        namespace=namespace,
-        field_selector="reason==Scheduled",
-        since_seconds=60,
-    )
-    assert len(events) > 0, "Pod was not scheduled"
-
- -

Collecting Events During an Operation

-

Use Event.get() with a timeout to capture all events that occur during an operation:

-
from ocp_resources.event import Event
-
-events = []
-for event in Event.get(
-    client=client,
-    namespace="my-namespace",
-    field_selector="involvedObject.kind==Deployment",
-    timeout=30,
-):
-    events.append(event.object)
-    if event.object.reason == "ScalingReplicaSet":
-        break  # Found what we were looking for
-
-print(f"Captured {len(events)} events")
-
- -

Watching Cluster-Wide Events

-

Omit the namespace parameter to watch events across all namespaces:

-
from ocp_resources.event import Event
-
-# Watch all Warning events cluster-wide
-for event in Event.get(
-    client=client,
-    field_selector="type==Warning",
-    timeout=60,
-):
-    ns = event.object.involvedObject.get("namespace", "cluster-scoped")
-    print(f"[{ns}] {event.object.reason}: {event.object.message}")
-
- -

Accessing Event Object Properties

-

The event.object yielded by Event.get() and resource.events() is a Kubernetes ResourceInstance with these commonly used attributes:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttributeDescription
event.object.type"Normal" or "Warning"
event.object.reasonShort reason string (e.g. "Scheduled")
event.object.messageHuman-readable event message
event.object.countNumber of times this event occurred
event.object.firstTimestampWhen the event first occurred
event.object.lastTimestampWhen the event most recently occurred
event.object.sourceDict with component and host
event.object.involvedObjectDict with kind, name, namespace, etc.
-

Troubleshooting

-

Event.get() hangs indefinitely -You likely omitted the timeout parameter. Always set a timeout value to avoid blocking forever:

-
# Bad — hangs if no events arrive
-for event in Event.get(client=client, namespace="ns"):
-    ...
-
-# Good — stops after 30 seconds
-for event in Event.get(client=client, namespace="ns", timeout=30):
-    ...
-
- -

Event.list() returns an empty list

-
    -
  • Check the since_seconds value. The default is 300 (5 minutes). If the events are older, increase the value.
  • -
  • Verify the namespace is correct.
  • -
  • Ensure the field_selector syntax uses == (double equals), not =.
  • -
-

resource.events() returns events for other resources too -This method filters only by involvedObject.name. If multiple resources share the same name across different kinds, add a field_selector to narrow it down:

-
for event in pod.events(
-    field_selector="involvedObject.kind==Pod",
-    timeout=10,
-):
-    print(event.object.message)
-
- -
-

Warning: The dyn_client parameter on Event.get() and Event.delete_events() is deprecated and will be removed in the next major release. Use client instead.

-
- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - diff --git a/docs/working-with-virtual-machines.html b/docs/working-with-virtual-machines.html deleted file mode 100644 index 20d0b09089..0000000000 --- a/docs/working-with-virtual-machines.html +++ /dev/null @@ -1,1192 +0,0 @@ - - - - - - Working with Virtual Machines (KubeVirt) - openshift-python-wrapper - - - - - - - - - -
-
-
- - openshift-python-wrapper -
-
-
- - - ⚡ pi-docsite - - - - - - - - -
-
- -
- -
-
- -

Working with Virtual Machines (KubeVirt)

-

Manage the full lifecycle of KubeVirt virtual machines on OpenShift — create VMs, start and stop them, live-migrate between nodes, take snapshots, and interact with their running instances — all from Python.

-

Prerequisites

- -

Quick Example

-
from ocp_resources.virtual_machine import VirtualMachine
-
-vm = VirtualMachine(
-    name="my-vm",
-    namespace="my-namespace",
-    body={
-        "spec": {
-            "domain": {
-                "devices": {"disks": [{"disk": {"bus": "virtio"}, "name": "rootdisk"}]},
-                "resources": {"requests": {"memory": "1Gi"}},
-            },
-            "volumes": [{"name": "rootdisk", "containerDisk": {"image": "quay.io/containerdisks/fedora"}}],
-        }
-    },
-)
-
-with vm:
-    vm.start(wait=True)
-    print(f"VM ready: {vm.ready}")  # True
-    print(f"Status: {vm.printable_status}")  # "Running"
-    vm.stop(wait=True)
-# VM is automatically cleaned up when the context manager exits
-
- -

Creating a VirtualMachine

-
from ocp_resources.virtual_machine import VirtualMachine
-
-vm = VirtualMachine(
-    name="my-vm",
-    namespace="my-namespace",
-    body={
-        "spec": {
-            "domain": {
-                "devices": {"disks": [{"disk": {"bus": "virtio"}, "name": "rootdisk"}]},
-                "resources": {"requests": {"memory": "2Gi"}},
-            },
-            "volumes": [
-                {
-                    "name": "rootdisk",
-                    "containerDisk": {"image": "quay.io/containerdisks/fedora"},
-                }
-            ],
-        }
-    },
-)
-vm.deploy()
-
- -

The body parameter accepts a dictionary that maps to the KubeVirt VM spec.template structure. If you omit body, a minimal skeleton ({"template": {"spec": {}}}) is created automatically.

-

You can also create a VM from a YAML file:

-
vm = VirtualMachine(
-    name="my-vm",
-    namespace="my-namespace",
-    yaml_file="vm-definition.yaml",
-)
-vm.deploy()
-
- -

Constructor Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
namestrNoneName of the VirtualMachine resource
namespacestrNoneTarget namespace
clientDynamicClientNoneKubernetes client; auto-resolved if omitted
bodydictNoneVM spec body dictionary
teardownboolTrueWhether to delete the resource on cleanup
yaml_filestrNonePath to a YAML file defining the VM
delete_timeoutint240 (seconds)Timeout for delete operations
-
-

Tip: Use the context manager (with vm:) to ensure automatic cleanup. See Creating and Managing Resources for details on deploy(), clean_up(), and context managers.

-
-

Starting, Stopping, and Restarting

-

Start a VM

-
# Fire-and-forget
-vm.start()
-
-# Wait until the VM is ready
-vm.start(wait=True)
-
-# Custom timeout (seconds)
-vm.start(timeout=600, wait=True)
-
- -

Stop a VM

-
# Fire-and-forget
-vm.stop()
-
-# Wait for the VM to stop and the VMI to be deleted
-vm.stop(wait=True)
-
-# Custom timeouts
-vm.stop(timeout=300, vmi_delete_timeout=300, wait=True)
-
- -

Restart a VM

-
vm.restart(wait=True)
-
- -

When wait=True, restart() waits for the old virt-launcher pod to be deleted and the new VMI to reach the Running state.

-

Checking VM Status

-
# Boolean ready status: True if running, None if stopped
-vm.ready  # True
-
-# Human-readable status string
-vm.printable_status  # "Running", "Stopped", "Starting", "Migrating", etc.
-
-# Wait for ready status
-vm.wait_for_ready_status(status=True, timeout=300)  # Wait for running
-vm.wait_for_ready_status(status=None, timeout=300)  # Wait for stopped
-
-# Wait for a specific status field to clear
-vm.wait_for_status_none(status="snapshotInProgress")
-
- -

Run Strategy Constants

-

Use built-in constants for the runStrategy field:

-
VirtualMachine.RunStrategy.ALWAYS  # "Always"
-VirtualMachine.RunStrategy.HALTED  # "Halted"
-VirtualMachine.RunStrategy.MANUAL  # "Manual"
-VirtualMachine.RunStrategy.RERUNONFAILURE  # "RerunOnFailure"
-
- -

Status Constants

-
VirtualMachine.Status.STARTING  # "Starting"
-VirtualMachine.Status.RUNNING  # "Running"  (inherited)
-VirtualMachine.Status.STOPPED  # "Stopped"
-VirtualMachine.Status.STOPPING  # "Stopping"
-VirtualMachine.Status.MIGRATING  # "Migrating"
-VirtualMachine.Status.PAUSED  # "Paused"
-VirtualMachine.Status.PROVISIONING  # "Provisioning"
-VirtualMachine.Status.ERROR_UNSCHEDULABLE  # "ErrorUnschedulable"
-VirtualMachine.Status.DATAVOLUME_ERROR  # "DataVolumeError"
-
- -

Working with VirtualMachineInstances

-

Every running VM has an associated VirtualMachineInstance (VMI). Access it through the VM or create one directly.

-

Accessing the VMI from a VM

-
vmi = vm.vmi  # Returns a VirtualMachineInstance object
-
- -

Creating a Standalone VMI

-
from ocp_resources.virtual_machine_instance import VirtualMachineInstance
-
-vmi = VirtualMachineInstance(
-    name="my-vmi",
-    namespace="my-namespace",
-    domain={
-        "devices": {"disks": [{"disk": {"bus": "virtio"}, "name": "rootdisk"}]},
-        "resources": {"requests": {"memory": "1Gi"}},
-    },
-    volumes=[{"name": "rootdisk", "containerDisk": {"image": "quay.io/containerdisks/fedora"}}],
-)
-vmi.deploy()
-
- -
-

Note: The domain parameter is required when creating a VMI programmatically. Omitting it raises MissingRequiredArgumentError.

-
-

VMI Constructor Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeRequiredDescription
domaindictYesDomain spec (CPU, memory, devices)
volumeslistNoVolumes to mount
networkslistNoNetwork interfaces
affinitydictNoScheduling affinity rules
node_selectordictNoNode label selector
tolerationslistNoToleration rules
eviction_strategystrNo"LiveMigrate", "LiveMigrateIfPossible", "None", or "External"
termination_grace_period_secondsintNoSeconds to wait before force-killing
hostnamestrNoVM hostname
priority_class_namestrNoPod priority class
scheduler_namestrNoCustom scheduler name
start_strategystrNoSet to "Paused" to start paused
access_credentialslistNoPublic keys to inject
topology_spread_constraintslistNoTopology spread rules
-

Waiting for a VMI to Run

-
vmi.wait_until_running(timeout=300)
-
- -

If the VMI fails to start, wait_until_running raises TimeoutExpiredError and logs the virt-launcher pod status and logs for debugging. Set logs=False to suppress this:

-
vmi.wait_until_running(timeout=300, logs=False)
-
- -

You can also specify a stop_status to abort early if the VMI enters a failure state:

-
vmi.wait_until_running(timeout=300, stop_status="Failed")
-
- -

Pausing and Unpausing

-
vmi.pause(wait=True)  # Pause the VMI
-vmi.unpause(wait=True)  # Resume the VMI
-
- -

Resetting a VMI

-
vmi.reset()  # Hard-reset the VMI (like pressing the reset button)
-
- -

Getting the VMI Node

-
node = vmi.get_node()
-print(node.name)
-
- -

Network Interfaces

-
# All interfaces from the VMI status
-interfaces = vmi.interfaces
-
-# Get IP address for a specific interface
-ip = vmi.interface_ip("eth0")
-
- -

Guest Agent Information

-

When the QEMU guest agent is installed in the VM:

-
vmi.guest_os_info  # OS information
-vmi.guest_fs_info  # Filesystem information
-vmi.guest_user_info  # Logged-in user information
-vmi.os_version  # OS version string
-
- -
-

Note: If no guest agent is installed, os_version returns an empty dict and logs a warning.

-
-

Accessing the Virt-Launcher Pod

-
pod = vmi.get_virt_launcher_pod()
-print(pod.name)
-print(pod.status)
-
-# Access logs from the compute container
-logs = pod.log(container="compute")
-
- -

For clusters where a privileged client is required:

-
pod = vmi.get_virt_launcher_pod(privileged_client=admin_client)
-
- -

Accessing the Virt-Handler Pod

-
handler_pod = vmi.get_virt_handler_pod(privileged_client=admin_client)
-
- -

Executing Virsh Commands

-

Run virsh commands inside the virt-launcher pod:

-
# Get the XML definition
-xml_output = vmi.get_xml()
-
-# Get the XML as a parsed Python dict
-xml_dict = vmi.get_xml_dict()
-
-# Get domain memory stats
-memstats = vmi.get_dommemstat()
-
-# Run any arbitrary virsh command
-output = vmi.execute_virsh_command(command="dominfo")
-
- -

Checking Root Status

-
pod = vmi.get_virt_launcher_pod()
-
-# Check if the virt-launcher pod runs as root
-VirtualMachineInstance.is_pod_root(pod=pod)  # True or False
-
-# Get the pod's runAsUser UID
-VirtualMachineInstance.get_pod_user_uid(pod=pod)  # int or None
-
- -

Live Migration

-

Trigger a live migration by creating a VirtualMachineInstanceMigration:

-
from ocp_resources.virtual_machine_instance_migration import VirtualMachineInstanceMigration
-
-migration = VirtualMachineInstanceMigration(
-    name="migrate-my-vm",
-    namespace="my-namespace",
-    vmi_name="my-vm",
-)
-migration.deploy()
-
- -

Monitor migration by checking the VMI's status. Once migration completes, the VMI runs on the target node. See Waiting for Resource Conditions and Status for condition-based waiting.

-

Migration Resource Quotas

-

Control resource allocation for migrations:

-
from ocp_resources.virtual_machine_migration_resource_quota import VirtualMachineMigrationResourceQuota
-
-quota = VirtualMachineMigrationResourceQuota(
-    name="migration-quota",
-    namespace="my-namespace",
-    requests_cpu="2",
-    requests_memory="4Gi",
-    limits_cpu="4",
-    limits_memory="8Gi",
-)
-quota.deploy()
-
- -

Snapshots and Restores

-

Taking a Snapshot

-
from ocp_resources.virtual_machine_snapshot import VirtualMachineSnapshot
-
-snapshot = VirtualMachineSnapshot(
-    name="my-vm-snapshot",
-    namespace="my-namespace",
-    vm_name="my-vm",
-)
-snapshot.deploy()
-
-# Wait for the snapshot to be ready
-snapshot.wait_ready_to_use(timeout=300)
-
-# Wait for both snapshot readiness AND the VM's snapshotInProgress to clear
-snapshot.wait_snapshot_done(timeout=300)
-
- -

Restoring from a Snapshot

-
from ocp_resources.virtual_machine_restore import VirtualMachineRestore
-
-restore = VirtualMachineRestore(
-    name="my-vm-restore",
-    namespace="my-namespace",
-    vm_name="my-vm",
-    snapshot_name="my-vm-snapshot",
-)
-restore.deploy()
-
-# Wait for restore to complete
-restore.wait_restore_done(timeout=300)
-
- -

The wait_restore_done() method checks both the restore's complete status and that the VM's restoreInProgress field is cleared.

-
- - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
vm_namestrTarget VirtualMachine name
snapshot_namestrSource VirtualMachineSnapshot name
volume_restore_policystrOptional policy for volume restoration
-

Cloning VMs

-
from ocp_resources.virtual_machine_clone import VirtualMachineClone
-
-clone = VirtualMachineClone(
-    name="clone-my-vm",
-    namespace="my-namespace",
-    source_name="my-vm",
-    target_name="my-vm-clone",
-)
-clone.deploy()
-
- -
-

Note: source_name is required. If omitted, MissingRequiredArgumentError is raised.

-
-

Clone Parameters

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDefaultDescription
source_namestrRequiredName of the source VM
source_kindstr"VirtualMachine"Kind of the source resource
target_namestrAuto-generatedName for the cloned VM
label_filterslistNoneLabels to copy, e.g. ["*", "!someKey/*"]
annotation_filterslistNoneAnnotations to copy
new_mac_addressesdictNoneOverride MACs: {"nic0": "00:11:22:33:44:55"}
new_smbios_serialstrNoneOverride SMBIOS serial number
volume_name_policystrNoneVolume naming policy for the clone
-

Exporting VMs

-
from ocp_resources.virtual_machine_export import VirtualMachineExport
-
-export = VirtualMachineExport(
-    name="export-my-vm",
-    namespace="my-namespace",
-    source={"apiGroup": "kubevirt.io", "kind": "VirtualMachine", "name": "my-vm"},
-    token_secret_ref="my-export-token",
-    ttl_duration="1h",
-)
-export.deploy()
-
- -
-

Note: The source parameter is required. If omitted, MissingRequiredArgumentError is raised.

-
-

Advanced Usage

-

Instance Types and Preferences

-

KubeVirt instance types define reusable VM sizing profiles. Preferences define non-sizing defaults (clock, firmware, devices).

-

Namespaced Instance Type

-
from ocp_resources.virtual_machine_instancetype import VirtualMachineInstancetype
-
-instancetype = VirtualMachineInstancetype(
-    name="small",
-    namespace="my-namespace",
-    cpu={"guest": 2},
-    memory={"guest": "4Gi"},
-)
-instancetype.deploy()
-
- -

Both cpu and memory are required. Omitting either raises MissingRequiredArgumentError.

-

Cluster-Scoped Instance Type

-
from ocp_resources.virtual_machine_cluster_instancetype import VirtualMachineClusterInstancetype
-
-cluster_instancetype = VirtualMachineClusterInstancetype(
-    name="large",
-    cpu={"guest": 8},
-    memory={"guest": "16Gi"},
-    gpus=[{"deviceName": "nvidia.com/A100", "name": "gpu0"}],
-)
-cluster_instancetype.deploy()
-
- -

Namespaced Preference

-
from ocp_resources.virtual_machine_preference import VirtualMachinePreference
-
-preference = VirtualMachinePreference(
-    name="linux-pref",
-    namespace="my-namespace",
-    cpu={"preferredCPUTopology": "preferSockets"},
-    firmware={"preferredUseEfi": True},
-)
-preference.deploy()
-
- -

Cluster-Scoped Preference

-
from ocp_resources.virtual_machine_cluster_preference import VirtualMachineClusterPreference
-
-cluster_preference = VirtualMachineClusterPreference(
-    name="windows-pref",
-    clock={"preferredTimer": {"hpet": {"present": False}}},
-    features={"preferredHyperv": {"spinlocks": {"spinlocks": 8191}}},
-)
-cluster_preference.deploy()
-
- -

VMI Replica Sets

-

Scale out VMIs horizontally:

-
from ocp_resources.virtual_machine_instance_replica_set import VirtualMachineInstanceReplicaSet
-
-replica_set = VirtualMachineInstanceReplicaSet(
-    name="my-vmi-rs",
-    namespace="my-namespace",
-    replicas=3,
-    selector={"matchLabels": {"app": "my-vmi"}},
-    template={
-        "metadata": {"labels": {"app": "my-vmi"}},
-        "spec": {
-            "domain": {
-                "devices": {},
-                "resources": {"requests": {"memory": "1Gi"}},
-            }
-        },
-    },
-)
-replica_set.deploy()
-
- -

Both selector and template are required.

-

Storage Migration

-

Migrate VM storage between storage classes:

-
from ocp_resources.virtual_machine_storage_migration_plan import VirtualMachineStorageMigrationPlan
-from ocp_resources.virtual_machine_storage_migration import VirtualMachineStorageMigration
-
-# Create a migration plan
-plan = VirtualMachineStorageMigrationPlan(
-    name="migrate-storage",
-    namespace="my-namespace",
-    virtual_machines=[{"name": "my-vm", "volumes": [{"name": "rootdisk", "target": {"storageClassName": "fast-ssd"}}]}],
-    retention_policy="deleteSource",
-)
-plan.deploy()
-
-# Execute the migration
-migration = VirtualMachineStorageMigration(
-    name="migrate-storage-exec",
-    namespace="my-namespace",
-    virtual_machine_storage_migration_plan_ref={"name": "migrate-storage", "namespace": "my-namespace"},
-)
-migration.deploy()
-
- -

VM Templates

-
from ocp_resources.virtual_machine_template import VirtualMachineTemplate
-
-template = VirtualMachineTemplate(
-    name="fedora-template",
-    namespace="my-namespace",
-    virtual_machine={
-        "spec": {
-            "template": {
-                "spec": {
-                    "domain": {
-                        "devices": {},
-                        "resources": {"requests": {"memory": "2Gi"}},
-                    }
-                }
-            }
-        }
-    },
-    parameters=[{"name": "VM_NAME", "description": "Name of the VM", "required": True}],
-    message="Use this template to create Fedora VMs.",
-)
-template.deploy()
-
- -

Listing VMs Across Namespaces

-
# List all VMs in a namespace
-for vm in VirtualMachine.get(namespace="my-namespace"):
-    print(f"{vm.name}: {vm.instance.get('status', {}).get('printableStatus')}")
-
-# List with label selectors
-for vm in VirtualMachine.get(
-    namespace="my-namespace",
-    label_selector="app=webserver",
-):
-    print(vm.name)
-
- -

See Querying and Watching Resources for more query options.

-

Getting VM Network Interfaces

-
interfaces = vm.get_interfaces()
-for iface in interfaces:
-    print(f"Name: {iface['name']}, Model: {iface.get('model', 'default')}")
-
- -

Troubleshooting

-

VM Stuck in "Starting" Status

-

Check the VMI's virt-launcher pod for scheduling or image-pull issues:

-
vmi = vm.vmi
-try:
-    pod = vmi.get_virt_launcher_pod()
-    print(f"Pod status: {pod.status}")
-    print(pod.log(container="compute"))
-except ResourceNotFoundError:
-    print("Virt-launcher pod not found — VM may not have been scheduled yet")
-
- -

TimeoutExpiredError on start/stop

-

Increase the timeout parameter:

-
vm.start(timeout=600, wait=True)  # 10 minutes instead of default 4
-
- -

MissingRequiredArgumentError

-

This is raised when a required constructor parameter is missing. Common cases:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ResourceRequired Parameters
VirtualMachineInstancedomain
VirtualMachineInstancetypecpu, memory
VirtualMachineClonesource_name
VirtualMachineExportsource
VirtualMachineInstanceReplicaSetselector, template
VirtualMachineStorageMigrationvirtual_machine_storage_migration_plan_ref
VirtualMachineTemplatevirtual_machine
-

Guest Agent Not Reporting

-

If vmi.os_version returns an empty dict, the QEMU guest agent is not installed or not running inside the VM. Install and start qemu-guest-agent in the guest OS.

- - -
-
- - - - - -
- - -
- - - - - - - - - - - - - - - - From 923e5f91cd99605b1954a3d8208c27766286ab7a Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Fri, 6 Nov 2026 20:19:36 +0200 Subject: [PATCH 2/5] fix(docs): harden the docs workflows per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard: run on pull_request_target and read the changed paths from the REST API instead of a git diff, so a PR can no longer rewrite the check that inspects it (a pull_request workflow definition comes from the PR under review). Nothing is checked out, so no PR code executes; typechanges and non-ASCII/newline paths are covered as API statuses and JSON strings - docs.yml: pin pi-docsite@4.18.0 — the build output is what `deploy` publishes, so @latest would let an upstream release change the live site with no commit here; persist-credentials: false on checkout so the token is not left in .git/config for the third-party generator; rebuild when docs.yml itself changes (it carries the tagline) - CONTRIBUTING.md: pin the local build command to the same version Refs #2873 Assisted-by: PI (rits/deepseek-ai/DeepSeek-V4.1-Flash-1017) Signed-off-by: Meni Yakove --- .github/workflows/docs-guard.yml | 99 ++++++++++++++------------------ .github/workflows/docs.yml | 16 ++++-- CONTRIBUTING.md | 5 +- 3 files changed, 59 insertions(+), 61 deletions(-) diff --git a/.github/workflows/docs-guard.yml b/.github/workflows/docs-guard.yml index 8c93b0499c..0c18d828c7 100644 --- a/.github/workflows/docs-guard.yml +++ b/.github/workflows/docs-guard.yml @@ -1,76 +1,65 @@ name: Docs Guard -on: pull_request +# pull_request_target, NOT pull_request: with `pull_request` the workflow definition is taken from +# the PR under review, so a PR could keep this job's name and replace the check with `exit 0` in the +# same change that adds generated docs. `pull_request_target` always runs the copy on the base +# branch, so the check cannot be rewritten by the change it is checking. +# +# Nothing from the PR is checked out or executed: the changed paths are read from the REST API and +# filtered in jq. That keeps PR code (and a repository token in .git/config) out of reach, and it +# sidesteps git's path quoting — an API path arrives as a JSON string, so a non-ASCII or +# newline-containing name cannot dodge the patterns below. +# +# Consequence: because the base branch has no copy of this file yet, the check does not run on the +# PR that introduces it; it applies to every PR opened after it lands. +on: pull_request_target permissions: - contents: read + pull-requests: read jobs: no-generated-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Reject generated docs in the PR env: - BASE_REF: ${{ github.base_ref }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail + offenders="$(mktemp)" + trap 'rm -f "$offenders"' EXIT - # An absent base ref must fail loudly: falling through to a diff that errors, or to an - # empty diff, would report the guard as passing without having checked anything. - if [ -z "$BASE_REF" ]; then - echo "::error::BASE_REF is empty — cannot compute the PR diff." - exit 1 - fi - if ! git rev-parse --verify --quiet "origin/${BASE_REF}" > /dev/null; then - echo "::error::origin/${BASE_REF} is not a known ref (shallow clone or absent base)." - exit 1 - fi - - # The diff is captured to a file first so its exit status is checked on its own: piping - # git into grep without pipefail reports "no match" when the diff never ran, so an absent - # check would look exactly like a passing one. - diff_file="$(mktemp)" - trap 'rm -f "$diff_file"' EXIT - if ! git diff --name-status -z --diff-filter=AMR -M "origin/${BASE_REF}...HEAD" > "$diff_file"; then - echo "::error::git diff against origin/${BASE_REF} failed — refusing to report a pass." - exit 1 - fi - - # Only added/modified/renamed/copied files are a violation. A DELETION is the migration + # Only added/modified/renamed/copied files are a violation: a DELETION is the migration # itself (removing the committed output) and must pass, or the PR that fixes the rule can - # never satisfy it. Renames are checked on BOTH sides, so docs/foo.md -> docs/index.html - # is caught too. - # - # -z keeps git from quoting or escaping paths (that is core.quotePath, unrelated to the - # shell): quoted, an added docs/résumé.html arrives as "docs/r\303\251sum\303\251.html" - # and matches none of the patterns below — the guard would pass exactly the files it - # exists to reject. + # never satisfy it. A rename or copy is checked on BOTH sides, so + # docs/foo.md -> docs/index.html is caught too. A type change (file -> symlink) arrives as + # `modified`, which is included. # - # -z emits `status\0path\0` (`R100\0old\0new\0` for a rename), so status tokens are - # dropped by shape, not by column — there is no column left after NUL splitting. Fields - # are matched as they arrive and never re-joined with newlines: a path CAN itself contain - # a newline, and converting NUL -> newline splits `docs/evil\nx.html` into two lines that - # match none of the patterns, so the generated file would slip through. `read -d ''` fails - # at EOF, which only ends this while condition (set -e does not apply to a loop - # condition), so the last field is not dropped. - # - # Inverted allowlist: docs/**/*.md and docs/nav.json are the sources; everything else - # under docs/ is what .github/workflows/docs.yml builds. - offenders=() - while IFS= read -r -d '' field; do - [[ "$field" =~ ^[ACDMRTUXB][0-9]*$ ]] && continue - [[ "$field" == docs/* ]] || continue - [[ "$field" == docs/nav.json ]] && continue - [[ "$field" =~ ^docs/(.*/)?[^/]+\.md$ ]] && continue - offenders+=("$field") - done < "$diff_file" + # Inverted allowlist: docs/**/*.md and docs/nav.json are the sources; everything else under + # docs/ is what .github/workflows/docs.yml builds. Each offender is emitted with @json so a + # path containing a newline stays on one line instead of splitting into two non-matching + # ones. + if ! gh api --paginate -H "Accept: application/vnd.github+json" \ + "repos/${GH_REPO}/pulls/${PR_NUMBER}/files?per_page=100" \ + --jq ' + .[] + | select(.status != "removed") + | ((.previous_filename // empty), .filename) + | select(startswith("docs/")) + | select(. != "docs/nav.json") + | select(endswith(".md") | not) + | @json + ' > "$offenders"; then + # A listing that never ran must not be read as "nothing found". + echo "::error::could not list the pull request changed files — refusing to report a pass." + exit 1 + fi - if [ "${#offenders[@]}" -gt 0 ]; then + if [ -s "$offenders" ]; then echo "::error::Generated docs must not be committed — the site is built on main from the docs sources." - printf '%s\n' "${offenders[@]}" + while IFS= read -r line; do echo " $line"; done < "$offenders" echo "Everything under docs/ other than docs/**/*.md and docs/nav.json is build output of" echo ".github/workflows/docs.yml — edit the markdown source instead." exit 1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 101a4efa65..d7df2b0319 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,8 +5,10 @@ on: branches: [main] paths: # Any docs change, not just .md: the migration that removes the committed output touches - # only generated paths, and it must still deploy. + # only generated paths, and it must still deploy. The workflow file is listed because it + # carries the tagline, so editing it changes the built site. - "docs/**" + - ".github/workflows/docs.yml" # The first deploy after Pages is switched to build_type=workflow has no push to ride on. workflow_dispatch: @@ -30,15 +32,19 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + with: + # The build runs a third-party generator; leaving the token in .git/config would hand it + # to that process for the rest of the job. + persist-credentials: false - uses: astral-sh/setup-uv@v6 - name: Build site from docs/*.md - # Always the newest release: docs should not wait on a bump commit, and this job holds no - # publish rights (they are on `deploy`), so the blast radius of a bad upstream upload is a - # failed build, not a compromised deployment. + # Pinned, because this job's output is what `deploy` publishes: `@latest` would let a new + # upstream release change the live site with no commit here. Bump deliberately (and bump + # CONTRIBUTING.md's command with it) after reviewing the release. # --repo-url is explicit on purpose: pi-docsite otherwise derives it from `origin`, and a # build from a fork rewrites every repository link. run: >- - uvx pi-docsite@latest --docs-dir docs + uvx pi-docsite@4.18.0 --docs-dir docs --repo-url https://github.com/RedHatQE/openshift-python-wrapper --tagline "Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls" - uses: actions/upload-pages-artifact@v3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f69c078f5b..ba3ba5864e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,12 +52,15 @@ path under `docs/` other than `**/*.md` and `nav.json` (deletions are allowed). To build locally for debugging — the output is gitignored, so it never shows up in `git status`: ```bash -uvx pi-docsite --docs-dir docs \ +uvx pi-docsite@4.18.0 --docs-dir docs \ --project-name openshift-python-wrapper \ --repo-url https://github.com/RedHatQE/openshift-python-wrapper \ --tagline "Manage OpenShift and Kubernetes resources with clean, Pythonic code instead of raw API calls" ``` +The version is pinned to the one `.github/workflows/docs.yml` publishes with, so a local build +matches the site; bump both together after reviewing a release. + `--repo-url` matters: pi-docsite otherwise derives it from the local `origin`, so building from a fork rewrites every repository link. The tagline is the description shown in the sidebar and in each page's metadata; the workflow passes both for the same reason. From 1913acbd40d1e4d88d31354e66e3fb05263827c5 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Fri, 6 Nov 2026 20:22:53 +0200 Subject: [PATCH 3/5] docs(contributing): document the Pages publishing prerequisites The docs guard and the deletion of the generated output only make sense when Pages builds from the workflow: record how to check build_type, that Pages keeps serving the last successful deployment until the next one replaces it, that the merge push is what triggers the first workflow deploy, and the live-URL check. Refs #2873 Assisted-by: PI (rits/deepseek-ai/DeepSeek-V4.1-Flash-1017) Signed-off-by: Meni Yakove --- CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba3ba5864e..6285196b6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,30 @@ the workflow's property, not something a contributor has to prove. Links between pages use the built `.html` form, because that is what the published site serves. +### Publishing prerequisites + +Pages must be configured for **Actions** (one-time repository setting, not per-PR) — this repo is: + +```bash +gh api repos/RedHatQE/openshift-python-wrapper/pages --jq '{build_type, status}' +# {"build_type":"workflow","status":"built"} +``` + +With `build_type: legacy` the site is served straight from the committed `docs/` on `main`, and +removing that output would take the site down. With `build_type: workflow` the deployed artifact is +the only thing Pages serves, and Pages keeps serving the **last successful deployment** until the +next one replaces it — so a merge that stops tracking the output never leaves a missing page; the +push that merge produces builds and deploys the replacement. Check the live site, not the artifact: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://redhatqe.github.io/openshift-python-wrapper/quickstart.html +``` + +The first deploy after switching to `build_type: workflow` needs no manual run here: the workflow +does not exist on `main` until the change merges, and that merge push (`docs/**` in the path filter) +is what triggers the build and deploy. `gh workflow run docs.yml` (`workflow_dispatch`) is only for a +re-deploy with no new commit. + Adding a page: create `docs/.md` and add `` to `docs/nav.json`. A `.md` missing from `nav.json` is still built, placed under a fallback group, and reported. From c8823d27a1e56bfa69c163fbd972a60e1b88030d Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Fri, 6 Nov 2026 20:25:52 +0200 Subject: [PATCH 4/5] fix(docs): fail closed when a PR is too large for the files API The changed-files endpoint returns at most 3,000 entries, so on a larger PR a generated docs path could sit outside the returned set and the guard would report a pass without having listed it. Read changed_files from the PR object first and refuse to certify anything above the cap, with a message pointing at manual review. Refs #2873 Assisted-by: PI (rits/deepseek-ai/DeepSeek-V4.1-Flash-1017) Signed-off-by: Meni Yakove --- .github/workflows/docs-guard.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/docs-guard.yml b/.github/workflows/docs-guard.yml index 0c18d828c7..9965ca95fa 100644 --- a/.github/workflows/docs-guard.yml +++ b/.github/workflows/docs-guard.yml @@ -31,6 +31,15 @@ jobs: offenders="$(mktemp)" trap 'rm -f "$offenders"' EXIT + # The files endpoint stops at 3,000 entries, so on a bigger PR a generated path could sit + # outside the returned set and an "all clean" reading would certify what was never listed. + # Fail closed: such a PR is reviewed by a maintainer, not silently passed. + changed_files="$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq '.changed_files')" + if [ "${changed_files:-0}" -gt 3000 ]; then + echo "::error::This PR changes ${changed_files} files, above the 3,000-file API limit — the docs guard cannot list them all. Split the PR, or ask a maintainer to check the docs paths by hand." + exit 1 + fi + # Only added/modified/renamed/copied files are a violation: a DELETION is the migration # itself (removing the committed output) and must pass, or the PR that fixes the rule can # never satisfy it. A rename or copy is checked on BOTH sides, so From dcc5e4e7c089fe4263d72170effd313fbdcbea9b Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Fri, 6 Nov 2026 20:30:03 +0200 Subject: [PATCH 5/5] docs(contributing): verify the live page matches the build, not just HTTP 200 Pages keeps serving the last successful deployment when a new one fails, so a 200 on quickstart.html can describe the previous page. Document comparing the served page with a local build of the merged commit, alongside the workflow run lookup, and say why a status code alone is not a freshness check. Refs #2873\n\nAssisted-by: PI (rits/deepseek-ai/DeepSeek-V4.1-Flash-1017) Signed-off-by: Meni Yakove --- CONTRIBUTING.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6285196b6f..8608890a18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,10 +84,15 @@ With `build_type: legacy` the site is served straight from the committed `docs/` removing that output would take the site down. With `build_type: workflow` the deployed artifact is the only thing Pages serves, and Pages keeps serving the **last successful deployment** until the next one replaces it — so a merge that stops tracking the output never leaves a missing page; the -push that merge produces builds and deploys the replacement. Check the live site, not the artifact: +push that merge produces builds and deploys the replacement. Check the live site, not the artifact — +but a status code proves only that *something* is live, not that it is **current**: when a deploy +fails, Pages keeps serving the last successful one, so a `200` can describe the previous page. Build +locally from the merged commit (command above) and compare the served page with it: ```bash -curl -s -o /dev/null -w '%{http_code}\n' https://redhatqe.github.io/openshift-python-wrapper/quickstart.html +curl -s -o /tmp/live-quickstart.html https://redhatqe.github.io/openshift-python-wrapper/quickstart.html +cmp docs/quickstart.html /tmp/live-quickstart.html && echo "live quickstart.html matches this commit" +gh run list --branch main --workflow docs.yml --limit 1 # the run this check belongs to ``` The first deploy after switching to `build_type: workflow` needs no manual run here: the workflow