The entry point key is the Juju interface name (e.g. postgresql, mongodb_client).
The runner discovers validators by this key and matches them to charm relations.
Add validators-test-utils if your unit tests use it. Most validator tests
import stubs and helpers from validators.test_utils (make_charm_from_relation,
ApplicationStub, RelationRoleStub, RelationStub, etc.) — when
tests/unit/test_validator.py does this, declare validators-test-utils under
[project.optional-dependencies].dev, and add extras = ["dev"] (or extend an
existing extras list) to the package's entry in the root
$PROJECT_ROOT/pyproject.toml under [tool.poetry.dependencies]. Forgetting
this when the tests do use it is a recurring mistake — tests still pass locally
because validators-test-utils is already installed elsewhere in the monorepo
venv, masking that the package's own dependency graph is incomplete.
Naming convention: the [project] name field always uses dashes, even when the
directory or module uses underscores. For example, a validator in
validators/postgresql_client/ is named validators-postgresql-client in
pyproject.toml. Replace underscores with dashes when setting the package name.
Goal
Write, deploy, and validate a new Juju charm integration validator for the
interface named in the task. The result should be a working Python package
under validators/<name>/ with passing dev-validate output.
Steps
Determine the Juju interface name (e.g. postgresql, kafka, s3).
Search CharmHub for a charm that provides the interface and one that
requires it. Prefer widely-used charms on stable channels.
Write /tmp/spec.yaml describing a minimal two-charm deployment. Use a
dedicated model name like <interface>-test (not testing) so the
deployment is isolated and easy to clean up.
tests/__init__.py, tests/unit/__init__.py, and tests/unit/test_validator.py all exist.
No unexpected files or directories.
License header
Every .py file and pyproject.toml begins with the canonical two-line header:
# Copyright <year> Canonical Ltd.
# See LICENSE file for licensing details.
pyproject.toml
name is "validators-<name>" in kebab-case matching the directory name.
authors is [{name = "SQA Team", email = "solutionsqa@canonical.com"}].
requires-python = ">=3.10".
validators-base is in dependencies.
If tests/unit/test_validator.py imports from validators.test_utils,
validators-test-utils is declared under [project.optional-dependencies].dev,
and the root pyproject.toml entry for this package includes extras = ["dev"].
The entry-point key under [project.entry-points."endpoint_validators"]
is the exact Juju interface name.
validator.py
Class name follows <Interface>Validator PascalCase.
validate() calls _skipped_result_due_to_level(level) for unsupported levels.
Uses self.validate_schema(...) for required-field checks.
Uses self.resolve_secret(...) for Juju secret resolution.
No hardcoded charm names, model names, or endpoint strings.
No print() calls. No unused or wildcard imports.
tests/unit/test_validator.py
Produce verification evidence (workload-up and workload-down). Run at the
highest level the validator supports (check validate() in validator.py
-- use deep if implemented, otherwise simple):
If the backend is a raw Kubernetes deployment (not a Juju app — e.g. MinIO for s3),
the default juju scale-application down step won't break connectivity because the
Juju databag retains credentials even at 0 units. In that case use --down-cmd and
--restore-cmd to scale the k8s deployment directly:
The report is written to the --output-dir and persists on the host at
development-sandbox/reports/. Include the summary.txt and report.json
paths in your completion summary.
Many charms expose credentials via Juju secrets instead of plain databag fields.
The base class has a helper:
creds = self.resolve_secret("secret-user", "username", "password")
# Returns {"username": "...", "password": "..."} from secret or databag
Checking connectivity
For database validators, connect with the client library and run a probe query:
import psycopg2 # add to pyproject.toml dependencies as psycopg2-binary# Derive connection parameters from the relation databag
host = databag.get("host", "")
port = databag.get("port", "5432")
db = databag.get("database", "")
try:
creds = self.resolve_secret("secret-user", "username", "password")
conn = psycopg2.connect(
host=host, port=port, dbname=db,
user=creds["username"], password=creds["password"],
)
with conn.cursor() as cur:
cur.execute("SELECT 1")
conn.close()
checks.append(ValidationCheck(name="connectivity", passed=True, message="OK"))
except Exception as exc:
checks.append(ValidationCheck(name="connectivity", passed=False, message=str(exc)))
Adding a deep-level check
Return _skipped_result_due_to_level for levels you don't support. Only implement what you've tested:
defvalidate(self, level: ValidationLevel = "simple") -> ValidationResult:
if level == "uat":
returnself._skipped_result_due_to_level(level)
if level == "deep":
# do deeper checks
...
# simple checks always run
HTTP API helpers and canary resources
When decoding HTTP response bodies as JSON, wrap json.loads() in a
try/except json.JSONDecodeError on every response path (success and
error) — don't assume a 2xx response always has a JSON body.
When creating a canary/throwaway resource for a deep check (e.g. a
registered datasource), give it a unique name (e.g. uuid.uuid4().hex[:8]
suffix), not a deterministic one derived from app/model identifiers — a
crashed prior run or concurrent validation can otherwise collide on the
same name and cause spurious failures.
Validator-specific notes
dev-validate.py auto-reexecs via poetry run if invoked outside the Poetry venv, so you can call it directly without any manual prefix. Do not wrap it in poetry run yourself.
If a relation has no remote app (relation.app is None), return an ERROR result immediately.
Keep validators focused on a single interface. Do not add cross-interface logic.
Add client library dependencies (e.g. psycopg2-binary) to the validator's pyproject.tomldependencies.
Acceptance criteria
dev-validate exits 0 with all checks PASS at the highest supported level.
The validator package has correct pyproject.toml with entry point.
If the unit tests import from validators.test_utils, validators-test-utils
is declared under [project.optional-dependencies].dev in the validator's own
pyproject.toml, and the root pyproject.toml entry includes extras = ["dev"].
validators/runner/pyproject.toml includes validators-<name>.
Root $PROJECT_ROOT/pyproject.toml includes validators-<name> as a Poetry
develop dependency.
./scripts/format.sh exits 0 after all changes.
./scripts/lint.sh exits 0 after all changes.
Self-review complete: all structure, license, naming, and test coverage criteria met.
verify-validator.sh exits 0.
Verification evidence includes both workload-up pass and workload-down detection.
No hardcoded charm names or model names inside the validator code.
Defines AppStub, RelationStub, RelationMetaStub, CharmMetaStub,
CharmStub, and a _make_validator() factory using cast(ops.CharmBase, ...)
and cast(ops.Relation, ...).