Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# AGENTS.md -- ansible-elasticsearch

This file is the vendor-neutral agent brief (Cursor, Claude Code, DEX, and similar).
There is no `CLAUDE.md`. Read it before opening a pull request.

## What this repo is

Hostinger fork of [elastic/ansible-elasticsearch](https://github.com/elastic/ansible-elasticsearch).
It is the Ansible role that installs and configures Elasticsearch nodes for Hostinger's
observability clusters (INT / KUL / BOS).

Consumed from [ansible-infra](https://github.com/hostinger/ansible-infra) as the
`community.elasticsearch` role (git source, version-pinned). Cluster bootstrap,
inventory, and node sizing live in ansible-infra — this repo is the role only.
Owner: `@hostinger/observability`.

## Layout

```text
defaults/main.yml Role defaults (version, paths, API, SSL, X-Pack)
tasks/ Install, config, plugins, SSL, X-Pack/security
templates/ elasticsearch.yml, jvm.options, repo, security files
filter_plugins/custom.py Jinja filters used by tasks and templates
handlers/main.yml Restart/reload
molecule/ Integration scenarios: default, security, custom-config
tests/ Unit tests for filter_plugins (pytest)
```

Most work happens in `tasks/`, `templates/`, `defaults/main.yml`, and
`filter_plugins/custom.py`.

## Pull request titles

The GitHub PR title is the primary signal for humans, release-drafter, and coding
agents. It must describe the actual change so someone can understand the PR
without opening the diff.

Use [Conventional Commits](https://www.conventionalcommits.org):

```text
type: imperative summary
type(scope): imperative summary
```

Allowed types: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `ci`, `perf`.

**Required**

- Name the behaviour, file, or subsystem that changed (filters, SSL, molecule, FQCN, …).
- Match the title to the commits and the diff. If the work is a bug fix, the type is `fix:`, not `chore:` or `refactor:`.
- Keep it specific enough that an agent can decide whether the PR is relevant without reading the body.

**Forbidden** — these titles are not context-aware and must not be used:

- `Daily code improvement (YYYY-MM-DD)`
- `Weekly code improvement (YYYY-MM-DD)`
- `Daily code improvement`
- Generic hex/DEX labels, dated batch names, or “misc fixes”

| Bad | Good |
| --- | --- |
| `Daily code improvement (2026-09-08)` | `fix: avoid mutable default arguments in custom filter plugins` |
| `Weekly code improvement (2026-05-29)` | `refactor: extract shared reserved-entry predicate in custom filters` |
| `hex improvements` | `chore: qualify remaining module invocations with FQCNs` |

If a scheduled agent run produces several unrelated fixes, open **one PR per
change** (or at least one PR per concern), each with its own context-aware title.
Do not bundle them under a dated catch-all.

The PR title and the subject of the main commit should say the same thing.

## Commits

- Conventional Commits, imperative mood (`avoid`, `extract`, `qualify` — not `avoided` / `extracting`).
- Do not commit secrets, license files, or `.env`.

## Tests

- Filter changes: add or extend `tests/test_custom_filters.py` and run
`python3 -m pytest tests -q`.
- Role behaviour: Molecule scenarios `default`, `security`, `custom-config`
(`molecule test -s <scenario>`). CI runs Molecule on pull requests.
- Do not “fix” a filter by changing its default handling without a test that
pins omitted arguments and the previously untested call paths.

## Coupled changes

- New Jinja filter → register it in `FilterModule.filters()`, unit-test it, and
use it from a task or template.
- `es_config` / template change → check `templates/elasticsearch.yml.j2` and any
`es_config_*` override variables.
- Security user/role filters (`filter_reserved`, `extract_role_users`) → tasks
under `tasks/xpack/security/`. Call sites are positional Jinja pipes; keep
parameter names consistent across sibling filters.
- Role version bump is **not** this repo: ansible-infra pins
`community.elasticsearch` in `collections/requirements.yml`.

## Anti-patterns

- Do **not** use mutable default arguments (`values=[]`, `users={}`) in
`filter_plugins/`. Default to `None` and assign with `if x is None:` (not
`x = x or []`, which treats empty strings as missing).
- Do **not** edit rendered config on Elasticsearch hosts; change templates here.
- Do **not** add narrating comments that restate the next line.
- Do **not** retitle a specific change as a dated “daily/weekly improvement”.
32 changes: 23 additions & 9 deletions filter_plugins/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import os.path


def modify_list(values=[], pattern="", replacement="", ignorecase=False):
""" Perform a `re.sub` on every item in the list"""
def modify_list(values=None, pattern="", replacement="", ignorecase=False):
"""Perform a `re.sub` on every item in the list."""
if values is None:
values = []
if ignorecase:
flags = re.I
else:
Expand All @@ -14,19 +16,27 @@ def modify_list(values=[], pattern="", replacement="", ignorecase=False):
return [_re.sub(replacement, value) for value in values]


def append_to_list(values=[], suffix=""):
def append_to_list(values=None, suffix=""):
if values is None:
values = []
if isinstance(values, str):
values = values.split(",")
return [str(value + suffix) for value in values]


def array_to_str(values=[], separator=","):
def array_to_str(values=None, separator=","):
if values is None:
values = []
return separator.join(values)


def extract_role_users(users={}, exclude_users=[]):
def extract_role_users(users=None, exclude_users=None):
if users is None:
users = {}
if exclude_users is None:
exclude_users = []
role_users = []
for user, details in list(users.items()):
for user, details in users.items():
if user not in exclude_users and "roles" in details:
for role in details["roles"]:
role_users.append(role + ":" + user)
Expand All @@ -46,14 +56,18 @@ def _is_reserved(details):
)


def remove_reserved(user_roles={}):
def remove_reserved(user_roles=None):
"""Return the names of the entries that are NOT reserved."""
if user_roles is None:
user_roles = {}
return [name for name, details in user_roles.items() if not _is_reserved(details)]


def filter_reserved(users_role={}):
def filter_reserved(user_roles=None):
"""Return the names of the entries that ARE reserved."""
return [name for name, details in users_role.items() if _is_reserved(details)]
if user_roles is None:
user_roles = {}
return [name for name, details in user_roles.items() if _is_reserved(details)]


class FilterModule(object):
Expand Down
69 changes: 69 additions & 0 deletions tests/test_custom_filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from filter_plugins.custom import (
FilterModule,
append_to_list,
array_to_str,
extract_role_users,
filename,
filter_reserved,
modify_list,
remove_reserved,
)

Expand Down Expand Up @@ -40,3 +45,67 @@ def test_filters_are_registered():
filters = FilterModule().filters()
assert filters["filter_reserved"] is filter_reserved
assert filters["remove_reserved"] is remove_reserved


def test_modify_list_substitutes_every_item():
assert modify_list(["a-1", "a-2"], pattern="a-", replacement="b-") == [
"b-1",
"b-2",
]


def test_modify_list_honours_ignorecase():
assert modify_list(["A-1"], pattern="a-", replacement="b-") == ["A-1"]
assert modify_list(["A-1"], pattern="a-", replacement="b-", ignorecase=True) == [
"b-1"
]


def test_append_to_list_accepts_a_list_or_a_comma_separated_string():
assert append_to_list(["/data1", "/data2"], suffix="/es") == [
"/data1/es",
"/data2/es",
]
assert append_to_list("/data1,/data2", suffix="/es") == ["/data1/es", "/data2/es"]


def test_append_to_list_keeps_empty_string_as_string_input():
assert append_to_list("", suffix="/es") == ["/es"]


def test_array_to_str_joins_with_the_separator():
assert array_to_str(["/data1", "/data2"]) == "/data1,/data2"
assert array_to_str(["/data1", "/data2"], separator=";") == "/data1;/data2"


def test_extract_role_users_pairs_each_role_with_its_user():
users = {
"es_admin": {"roles": ["admin"]},
"test_user": {"roles": ["power_user", "user"]},
"no_roles": {"password": "changeMe"},
}
assert sorted(extract_role_users(users)) == [
"admin:es_admin",
"power_user:test_user",
"user:test_user",
]


def test_extract_role_users_skips_excluded_users():
users = {"es_admin": {"roles": ["admin"]}, "kibana": {"roles": ["kibana_system"]}}
assert extract_role_users(users, exclude_users=["kibana"]) == ["admin:es_admin"]


def test_filename_strips_directory_and_extension():
assert filename("/tmp/templates/basic.json") == "basic"


def test_filters_tolerate_omitted_arguments():
"""No filter should rely on a mutable default argument."""
assert modify_list() == []
assert append_to_list() == []
assert array_to_str() == ""
assert extract_role_users() == []
assert filter_reserved() == []
assert remove_reserved() == []
assert filename() == ""
Comment thread
LukoJy3D marked this conversation as resolved.