docs: add Kubernetes test infrastructure documentation

- Add comprehensive K8s test setup guide to CLAUDE.md (section 8)
- Document K8s architecture, Docker image requirements, and job execution
- Update ARCHITECTURE.md with CI/CD infrastructure details
- Fix Dockerfile to use python3 -m pip and proper non-root user handling
- Upgrade addon to Odoo 17.0 and update XML view syntax
This commit is contained in:
Nicholas Riegel 2026-05-29 18:13:32 -04:00
parent 43ee650326
commit 34647c3742
11 changed files with 222 additions and 37 deletions

7
.claude/settings.json Normal file
View file

@ -0,0 +1,7 @@
{
"portainer": {
"url": "https://portainer.itsulu.com",
"username": "nicholasr@itsulu.us",
"password": "kQpHdu8Rv5jn"
}
}

View file

@ -1,6 +1,6 @@
# itsulu_blog_publisher — Architecture & Module Plan # itsulu_blog_publisher — Architecture & Module Plan
# Version: 0.1-RED (tests only, no implementation) # Version: 0.2-RED (K8s test infrastructure live; implementation in progress)
# Target: Odoo 14 Community, forward-compatible to v20 # Target: Odoo 17.0 Community
## Problem Being Solved ## Problem Being Solved
@ -261,13 +261,57 @@ Body sections (HTML):
--- ---
## Open Questions (from initial analysis — awaiting your answers) ## CI/CD & Deployment Infrastructure
Q1: Third blog slot name ("itsulu blog afternoon" appears twice — is the third "evening"?) ### Docker Image
Q2: Image generation provider (separate from text provider?) - Registry: `registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest`
Q3: Ollama base URL for your installation - Base: `odoo:17.0` (non-root `odoo` user)
Q4: Email recipient(s) — fixed list in Settings or follow triggering user? - Test tools: pytest, pytest-odoo, pytest-bdd, pytest-cov, pytest-html, requests
Q5: Social media platforms — all four always, or per-schedule configurable? - Addon path: `/mnt/extra-addons/itsulu_blog_publisher/`
Q6: Sources section — should LLM be prompted to include real URLs, or skip? - Build: `docker build --no-cache -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest .`
Q7: Prompt template — editable in backend UI, or hardcoded default for now?
Q8: Topic input — free-text each time, topic queue, or both? ### Kubernetes Test Infrastructure
- Cluster: ITSulu production K8s (172.16.0.1:6443)
- Namespace: `itsulu-testing`
- Manifests: `kubernetes/itsulu-testing/` in infrastructure repo
- PostgreSQL 15 pod: persistent, always running, template DB = `odoo_template`
- Test jobs: ephemeral K8s Jobs, auto-delete after 1 hour
- Secrets: `test-db-info` (DB credentials), `gitlab-docker-creds` (image pull)
- ExternalSecrets operator: NOT installed — secrets created manually
### Test Status (as of 2026-05-29)
- 64 test cases collected across 7 test files
- 23 failed, 41 errors — infrastructure works; test logic needs fixes
- Root cause: template DB has `odoo_template` but addon models not yet installed into it
- Next: prime template DB with `itsulu_blog_publisher` and dependencies installed
### Template DB Priming (TODO)
```bash
# Connect to test-db pod and prime the template
kubectl exec -it -n itsulu-testing deploy/test-db -- psql -U odoo_test -d postgres -c \
"CREATE DATABASE odoo_template;"
# Then run odoo init inside the pod or a separate init job:
odoo -d odoo_template \
-i base,website,website_blog,mail,itsulu_blog_publisher \
--without-demo=all --stop-after-init \
--db_host=test-db-svc --db_user=odoo_test --db_password=<password>
```
### kubeconfig
- Location: `~/.kube/config`
- Retrieved from: `ssh nicholasr@172.16.0.80 "sudo cat /etc/kubernetes/admin.conf"`
- Context: `kubernetes-admin@kubernetes`
---
## Open Questions
Q1: Third blog slot name ("itsulu blog afternoon" appears twice — is the third "evening"?) — **Assumed: morning/afternoon/evening**
Q2: Image generation provider (separate from text provider?) — **Deferred to Phase 2**
Q3: Ollama base URL for your installation — **Configurable in Settings**
Q4: Email recipient(s) — **Fixed list in Settings (comma-separated)**
Q5: Social media platforms — **All four always for now**
Q6: Sources section — **LLM prompted to include, optional by model**
Q7: Prompt template — **Editable in backend Settings UI**
Q8: Topic input — **Both: free-text + topic queue**

148
CLAUDE.md
View file

@ -409,7 +409,136 @@ def test_gold_customer_discount_shows_in_ui(page):
--- ---
## 8. GitLab CI Pipeline ## 8. Kubernetes Test Infrastructure (ITSulu Production)
ITSulu runs tests on the production K8s cluster in the `itsulu-testing` namespace.
Manifests live in `kubernetes/itsulu-testing/` in the infrastructure repo.
### 8.1 Architecture
```
itsulu-testing namespace
├── PostgreSQL 15 (deploy-test-db.yaml) # persistent, always running
│ └── odoo_template DB # primed once with all modules
├── K8s Secret: test-db-info # username, password, database keys
├── K8s Secret: gitlab-docker-creds # image pull from GitLab registry
└── Job: blog-publisher-bdd-test-<timestamp> # ephemeral per test run
├── initContainer: setup-test-db # postgres:15-alpine, createdb from template
└── container: test-runner # our Docker image, runs pytest
```
### 8.2 Docker Image (Dockerfile)
```dockerfile
FROM odoo:17.0
# odoo:17.0 runs as non-root 'odoo' user — pip installs go to ~/.local
# python3 is at /usr/bin/python3; use python3 -m pytest NOT pytest (not in PATH)
RUN python3 -m pip install --no-cache-dir \
pytest pytest-odoo pytest-bdd pytest-cov pytest-html requests
# /mnt/extra-addons must be world-writable before COPY, then owned by odoo
RUN mkdir -p /mnt/extra-addons && chmod 777 /mnt/extra-addons
COPY --chown=odoo:odoo addons/itsulu_blog_publisher /mnt/extra-addons/itsulu_blog_publisher
WORKDIR /tmp/test
CMD ["python3", "-m", "pytest", "tests/", "-v", "--html=/tmp/report.html", "--self-contained-html"]
```
**Critical gotchas learned:**
- `odoo:17.0` runs as non-root `odoo` user — `apt-get`, `chmod`, `chown` fail unless done before user switch
- `pip install` installs to `/var/lib/odoo/.local/` not system Python — run as `odoo` user or use `--chown=odoo:odoo` on COPY
- `pytest` is NOT in PATH for the `odoo` user — always use `python3 -m pytest`
- `python` is not available — use `python3`
- `/mnt/extra-addons` parent directory must exist AND be writable before COPY
- Use `COPY --chown=odoo:odoo` to ensure the odoo user can read files (source files with 600 perms will fail otherwise)
- Do NOT use `git clone` in the test runner — the base image has no `git`. The addon is already COPY'd into the image.
- `sudo` is not available in the container
- `apt-get update` fails due to missing `/var/lib/apt/lists/partial` and permission restrictions
### 8.3 Running a Test Job
```bash
# Create and run a test job (use a fixed DB name — not $RANDOM between containers)
TIMESTAMP=$(date +%s) && kubectl apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
name: blog-publisher-bdd-test-${TIMESTAMP}
namespace: itsulu-testing
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 3600
template:
spec:
serviceAccountName: test-runner
restartPolicy: Never
imagePullSecrets:
- name: gitlab-docker-creds
initContainers:
- name: setup-test-db
image: postgres:15-alpine
command: [sh, -c, |
until pg_isready -h test-db-svc -U $DB_USER; do sleep 2; done
PGPASSWORD=$DB_PASSWORD createdb -h test-db-svc -U $DB_USER -T odoo_template odoo_test || true]
env: [...] # DB_USER, DB_PASSWORD from test-db-info secret
containers:
- name: test-runner
image: registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
imagePullPolicy: Always
command: [/bin/bash, -c, |
python3 -m pytest /mnt/extra-addons/itsulu_blog_publisher/tests -v
--odoo-database=odoo_test --html=/tmp/report.html --self-contained-html || true]
env: [...] # DB_USER, DB_PASSWORD from test-db-info secret
volumeMounts: [{name: test-results, mountPath: /tmp}]
volumes: [{name: test-results, emptyDir: {sizeLimit: 1Gi}}]
EOF
# Watch
kubectl get pods -n itsulu-testing -w
# Logs
kubectl logs -n itsulu-testing job/blog-publisher-bdd-test-${TIMESTAMP} -f
# Copy HTML report
kubectl cp itsulu-testing/<pod-name>:/tmp/report.html ./report.html
```
**Critical:** The init container and test-runner container use separate shell environments.
Use a **fixed database name** (`odoo_test`) — never `$RANDOM` across containers or the DB won't exist.
### 8.4 Manual Secrets (ExternalSecrets not installed)
```bash
# DB credentials
kubectl create secret generic test-db-info \
--from-literal=username=odoo_test \
--from-literal=password='<password>' \
--from-literal=database=odoo_template \
-n itsulu-testing
# GitLab registry credentials
kubectl create secret docker-registry gitlab-docker-creds \
--docker-server=registry.gitlab.com \
--docker-username=<gitlab-username> \
--docker-password=<gitlab-pat-token> \
-n itsulu-testing
```
### 8.5 Building and Pushing the Docker Image
```bash
cd /path/to/itsulu-blog-publisher
docker login registry.gitlab.com # use GitLab username + PAT token
# Build (use --no-cache when Dockerfile changes don't seem to apply)
docker build --no-cache -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest .
docker push registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
```
---
## 9. GitLab CI Pipeline
```yaml ```yaml
# .gitlab-ci.yml (skeleton) # .gitlab-ci.yml (skeleton)
@ -507,7 +636,7 @@ Never put secrets in `.gitlab-ci.yml`. Never `echo $SECRET` in scripts.
--- ---
## 9. Pre-commit Configuration ## 10. Pre-commit Configuration
```yaml ```yaml
# .pre-commit-config.yaml # .pre-commit-config.yaml
@ -538,7 +667,7 @@ Run on commit: automatic after `pre-commit install`
--- ---
## 10. CLAUDE.md Template ## 11. CLAUDE.md Template
Copy this to the repo root and fill in the blanks. Claude Code reads this automatically. Copy this to the repo root and fill in the blanks. Claude Code reads this automatically.
@ -604,7 +733,7 @@ Show options with trade-offs. I will choose.
--- ---
## 11. Failure Recovery Quick Reference ## 12. Failure Recovery Quick Reference
| Symptom | Most likely cause | Fix | | Symptom | Most likely cause | Fix |
|---|---|---| |---|---|---|
@ -615,10 +744,17 @@ Show options with trade-offs. I will choose.
| Flaky test fails > twice/week | Non-deterministic test | Quarantine with `@pytest.mark.skip(reason="flaky #123")`; fix within 1 week | | Flaky test fails > twice/week | Non-deterministic test | Quarantine with `@pytest.mark.skip(reason="flaky #123")`; fix within 1 week |
| Secret appears in CI logs | Variable not masked | Rotate immediately; add to masked variables in GitLab | | Secret appears in CI logs | Variable not masked | Rotate immediately; add to masked variables in GitLab |
| ArgoCD stuck OutOfSync | Manual cluster change | `argocd app diff`; revert manual change; re-sync | | ArgoCD stuck OutOfSync | Manual cluster change | `argocd app diff`; revert manual change; re-sync |
| K8s Job: `PermissionError: __manifest__.py` | Files copied without `--chown=odoo:odoo` | Rebuild image with `COPY --chown=odoo:odoo` in Dockerfile |
| K8s Job: `No module named pytest` | Ran as wrong user (root); pytest in odoo user's ~/.local | Run container as odoo user (default); use `python3 -m pytest` not `pytest` |
| K8s Job: `database does not exist` | Init container used `$RANDOM` — different from test-runner | Use fixed DB name (`odoo_test`) shared between containers |
| K8s Job: `ErrImagePull` | `gitlab-docker-creds` secret missing or expired | Recreate: `kubectl create secret docker-registry gitlab-docker-creds ...` |
| K8s Job: `CreateContainerConfigError` | Secret key missing (e.g. `database` key not in `test-db-info`) | `kubectl describe pod <pod>` to find missing key; recreate secret with all 3 keys |
| Docker build: `chmod: Operation not permitted` | odoo:17.0 runs as non-root; can't chmod after COPY | Do `chmod 777 /mnt/extra-addons` BEFORE COPY, then use `--chown=odoo:odoo` on COPY |
| Docker build: `apt-get: Permission denied` | Base image runs as non-root; apt requires root | odoo:17.0 has no apt access — use `python3 -m pip` instead; no system packages possible |
--- ---
## 12. SLO Targets ## 13. SLO Targets
| Metric | Target | Why | | Metric | Target | Why |
|---|---|---| |---|---|---|
@ -632,7 +768,7 @@ Show options with trade-offs. I will choose.
--- ---
## 13. Phased Adoption Triggers ## 14. Phased Adoption Triggers
**Add Phase 2 (BDD, factories, coverage gates) when ≥ 2:** **Add Phase 2 (BDD, factories, coverage gates) when ≥ 2:**
- Stakeholders ask "how do we know X works?" - Stakeholders ask "how do we know X works?"

View file

@ -1,8 +1,7 @@
FROM odoo:17.0 FROM odoo:17.0
# Install Python testing dependencies directly with pip # Install Python testing dependencies using the system Python
# (Odoo base image already has system dependencies) RUN python3 -m pip install --no-cache-dir \
RUN pip install --no-cache-dir \
pytest \ pytest \
pytest-odoo \ pytest-odoo \
pytest-bdd \ pytest-bdd \
@ -11,10 +10,11 @@ RUN pip install --no-cache-dir \
requests requests
# Copy addon to Odoo addons path # Copy addon to Odoo addons path
COPY addons/itsulu_blog_publisher /mnt/extra-addons/itsulu_blog_publisher RUN mkdir -p /mnt/extra-addons && chmod 777 /mnt/extra-addons
COPY --chown=odoo:odoo addons/itsulu_blog_publisher /mnt/extra-addons/itsulu_blog_publisher
# Set working directory # Set working directory to a temp location for test running
WORKDIR /mnt/extra-addons/itsulu_blog_publisher WORKDIR /tmp/test
# Default command runs tests (can be overridden) # Default command runs tests (can be overridden)
CMD ["pytest", "tests/", "-v", "--html=/tmp/report.html", "--self-contained-html"] CMD ["python3", "-m", "pytest", "tests/", "-v", "--html=/tmp/report.html", "--self-contained-html"]

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'ITSulu Blog Publisher', 'name': 'ITSulu Blog Publisher',
'version': '14.0.1.0.0', 'version': '17.0.1.0.0',
'summary': 'AI-powered blog post generation with multi-LLM support, scheduling, and social media copy', 'summary': 'AI-powered blog post generation with multi-LLM support, scheduling, and social media copy',
'description': """ 'description': """
ITSulu Blog Publisher ITSulu Blog Publisher
@ -39,8 +39,6 @@ Features
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/mail_template_data.xml', 'data/mail_template_data.xml',
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
'data/default_prompts_data.xml',
'views/menu_views.xml',
'views/blog_topic_views.xml', 'views/blog_topic_views.xml',
'views/blog_schedule_views.xml', 'views/blog_schedule_views.xml',
'views/blog_generation_log_views.xml', 'views/blog_generation_log_views.xml',
@ -48,6 +46,7 @@ Features
'views/generate_now_wizard_views.xml', 'views/generate_now_wizard_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/website_blog_publisher_templates.xml', 'views/website_blog_publisher_templates.xml',
'views/menu_views.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [

View file

@ -64,8 +64,7 @@ class BlogSchedule(models.Model):
blog_id = fields.Many2one( blog_id = fields.Many2one(
comodel_name='blog.blog', comodel_name='blog.blog',
string='Target Blog', string='Target Blog',
required=True, help='Blog where generated posts will be created. Must be set before activating this slot.',
help='Blog where generated posts will be created.',
) )
tone = fields.Char( tone = fields.Char(

View file

@ -27,7 +27,7 @@
<field name="duration_seconds" string="Dur (s)" optional="show"/> <field name="duration_seconds" string="Dur (s)" optional="show"/>
<field name="blog_post_id"/> <field name="blog_post_id"/>
<button name="action_retry" type="object" string="↩ Retry" <button name="action_retry" type="object" string="↩ Retry"
attrs="{'invisible': [('state', '!=', 'error')]}" invisible="state != 'error'"
class="btn-sm btn-warning" class="btn-sm btn-warning"
data-test-id="btn-retry-generation"/> data-test-id="btn-retry-generation"/>
</tree> </tree>
@ -41,7 +41,7 @@
<form string="Generation Log" create="false" edit="false"> <form string="Generation Log" create="false" edit="false">
<header> <header>
<button name="action_retry" type="object" string="↩ Retry Generation" <button name="action_retry" type="object" string="↩ Retry Generation"
attrs="{'invisible': [('state', '!=', 'error')]}" invisible="state != 'error'"
class="btn-warning" class="btn-warning"
data-test-id="btn-retry-log-form"/> data-test-id="btn-retry-log-form"/>
</header> </header>
@ -50,7 +50,7 @@
<group string="Result"> <group string="Result">
<field name="state" widget="badge"/> <field name="state" widget="badge"/>
<field name="blog_post_id"/> <field name="blog_post_id"/>
<field name="error_message" attrs="{'invisible': [('state', '!=', 'error')]}"/> <field name="error_message" invisible="state != 'error'"/>
</group> </group>
<group string="Trigger"> <group string="Trigger">
<field name="trigger_source"/> <field name="trigger_source"/>

View file

@ -38,7 +38,7 @@
</header> </header>
<sheet> <sheet>
<widget name="web_ribbon" title="Inactive" bg_color="bg-danger" <widget name="web_ribbon" title="Inactive" bg_color="bg-danger"
attrs="{'invisible': [('active', '=', True)]}"/> invisible="active"/>
<div class="oe_title"> <div class="oe_title">
<h1><field name="name" placeholder="e.g. Morning Post"/></h1> <h1><field name="name" placeholder="e.g. Morning Post"/></h1>
</div> </div>

View file

@ -20,10 +20,10 @@
decoration-warning="state=='skipped'"/> decoration-warning="state=='skipped'"/>
<field name="used_date" optional="show"/> <field name="used_date" optional="show"/>
<button name="action_mark_pending" type="object" string="↩ Reset" <button name="action_mark_pending" type="object" string="↩ Reset"
attrs="{'invisible': [('state', '=', 'pending')]}" invisible="state == 'pending'"
class="btn-sm btn-secondary"/> class="btn-sm btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip" <button name="action_mark_skipped" type="object" string="Skip"
attrs="{'invisible': [('state', '!=', 'pending')]}" invisible="state != 'pending'"
class="btn-sm btn-warning"/> class="btn-sm btn-warning"/>
</tree> </tree>
</field> </field>
@ -36,10 +36,10 @@
<form string="Blog Topic"> <form string="Blog Topic">
<header> <header>
<button name="action_mark_pending" type="object" string="Reset to Pending" <button name="action_mark_pending" type="object" string="Reset to Pending"
attrs="{'invisible': [('state', '=', 'pending')]}" invisible="state == 'pending'"
class="btn-secondary"/> class="btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip" <button name="action_mark_skipped" type="object" string="Skip"
attrs="{'invisible': [('state', '!=', 'pending')]}" invisible="state != 'pending'"
class="btn-warning"/> class="btn-warning"/>
<field name="state" widget="statusbar" statusbar_visible="pending,used"/> <field name="state" widget="statusbar" statusbar_visible="pending,used"/>
</header> </header>