Compare commits

..
5 Commits
Author SHA1 Message Date
mrcharles 2440188f1f feat(tooling): containerize dns-cli in tooling-cli/dns
Consolidate the Technitium DNS CLI into tooling-cli/dns/ with a
containerized bash CLI (src/dns_cli.sh), Dockerfile (alpine +
curl/python3/bind-tools/bash), README, AGENTS.md, validate.sh, and
.env.example. Env vars aligned to the centralized ~/.creds/technitium.env
store (TECHNITIUM_URL, TECHNITIUM_DNS_TOKEN, TECHNITIUM_DNS_ZONE).

Image built and pushed to the registry as
git.knownelement.com/reachableceo/dns-cli:latest. Verified live:
zones, list, and search all return data.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-10 10:21:37 -05:00
mrcharles 67f8056e0b refactor(tooling): merge redmine-cli into tooling-cli/redmine
Consolidate the redmine-cli source into tooling-cli/redmine/
(CLI source, Dockerfile, README, AGENTS.md) with all documentation
rewritten to invoke the container via raw docker run and credentials
from the centralized ~/.creds/redmine.env store. No bin/ wrapper, no
system-dependent paths in the docs.

Removes the old redmine-cli/ subdirectory, updates the
KNELCredsManager consumer table, and updates STATUS.md + AGENTS.md
to reference the new location and registry image.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-10 09:53:42 -05:00
mrcharles ecffb4e598 refactor(tooling): merge discourse-cli into tooling-cli/discourse
Consolidate the discourse-cli source into tooling-cli/discourse/
(CLI source, Dockerfile, README, AGENTS.md, validate.sh) with all
documentation rewritten to invoke the container via raw docker run
and credentials from the centralized ~/.creds/discourse.env store.
No bin/ wrapper, no system-dependent paths in the docs.

Removes the old discourse-cli/ subdirectory and updates the
KNELCredsManager consumer table to reference the container directly.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-10 09:49:22 -05:00
mrcharles 1957dcbc7e feat(creds): centralize credentials to ~/.creds/, add KNELCredsManager
Rewire all MCP wrappers, docker-compose services, and validate scripts
to source credentials from ~/.creds/ instead of scattered per-service
.env files. This removes credential duplication and prepares for the
HashiCorp Vault migration.

Add KNELCredsManager under tooling-cli with:
- Containerized Bitwarden CLI (pinned image, host stays clean)
- scripts/bw wrapper with session management and data persistence
- README documenting credential layout, consumer wiring, and roadmap

Also fixes latent bug in MCP wrappers that were silently getting empty
creds from ambient shell env — they now explicitly source ~/.creds/.

Tracked in Redmine #407. Discourse: https://community.turnsys.com/t/308

💘 Generated with Crush

Assisted-by: Crush
2026-08-10 09:14:14 -05:00
mrcharles 7dfce930b6 fix: resolve subcategory slugs in category lookup and listing
The /categories.json endpoint only returns top-level categories by
default, so subcategory slugs (e.g. vp-techops) failed to resolve and
the 'categories' listing hid the entire subcategory tree. Both
_resolve_category and cmd_categories now pass include_subcategories=true
and traverse the nested subcategory_list.
2026-08-06 14:13:13 -05:00
31 changed files with 1292 additions and 79 deletions
+2 -2
View File
@@ -51,8 +51,8 @@ ALL work performed on this project MUST be documented in [JOURNAL.md](JOURNAL.md
- **Custom images**: publish to the Gitea container registry at - **Custom images**: publish to the Gitea container registry at
`git.knownelement.com/reachableceo/<image>:<tag>` (Docker is pre-authenticated). `git.knownelement.com/reachableceo/<image>:<tag>` (Docker is pre-authenticated).
- **Standalone CLI tools**: containerized CLI tools (not MCP/LSP servers) live - **Standalone CLI tools**: containerized CLI tools (not MCP/LSP servers) live
in their own top-level directory (e.g. `redmine-cli/`). Invoked on-demand via under `tooling-cli/` (e.g. `tooling-cli/redmine/`). Invoked on-demand via
`docker run --env-file .env <image> <command>`, not as long-running services. `docker run --env-file ~/.creds/<service>.env <image> <command>`, not as long-running services.
## Crush Configuration ## Crush Configuration
+1 -1
View File
@@ -217,4 +217,4 @@ Standalone containerized CLI tools (not MCP/LSP servers). Invoked on-demand via
| Tool | Image | Status | Notes | | Tool | Image | Status | Notes |
|------|-------|--------|-------| |------|-------|--------|-------|
| redmine-cli | kneldevstack-aimiddleware-redmine-cli | Working | Custom CLI (python-redmine) to list/show/create/update/close Redmine issues. Verified end-to-end against projects.knownelement.com. Requires REDMINE_URL and REDMINE_API_KEY. Source in `redmine-cli/`. | | redmine-cli | git.knownelement.com/reachableceo/redmine-cli:latest | Working | Custom CLI (python-redmine) to list/show/create/update/close Redmine issues. Verified end-to-end against projects.knownelement.com. Requires REDMINE_URL and REDMINE_API_KEY. Source in `tooling-cli/redmine/`. |
+6 -9
View File
@@ -282,10 +282,8 @@ services:
dockerfile: ../../dockerfiles/discourse-mcp/Dockerfile dockerfile: ../../dockerfiles/discourse-mcp/Dockerfile
container_name: kneldevstack-aimiddleware-discourse-mcp container_name: kneldevstack-aimiddleware-discourse-mcp
restart: unless-stopped restart: unless-stopped
environment: env_file:
- DISCOURSE_URL=${DISCOURSE_URL} - /home/reachableceo/.creds/discourse.env
- DISCOURSE_API_KEY=${DISCOURSE_API_KEY}
- DISCOURSE_API_USERNAME=${DISCOURSE_API_USERNAME}
profiles: profiles:
- ops - ops
@@ -398,10 +396,10 @@ services:
dockerfile: ../../dockerfiles/mcp-redmine/Dockerfile dockerfile: ../../dockerfiles/mcp-redmine/Dockerfile
container_name: kneldevstack-aimiddleware-mcp-redmine container_name: kneldevstack-aimiddleware-mcp-redmine
restart: "no" restart: "no"
env_file:
- /home/reachableceo/.creds/redmine.env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- REDMINE_URL=${REDMINE_URL}
- REDMINE_API_KEY=${REDMINE_API_KEY}
profiles: profiles:
- ops - ops
@@ -609,11 +607,10 @@ services:
dockerfile: ../../dockerfiles/beszel-mcp/Dockerfile dockerfile: ../../dockerfiles/beszel-mcp/Dockerfile
container_name: kneldevstack-aimiddleware-beszel-mcp container_name: kneldevstack-aimiddleware-beszel-mcp
restart: "no" restart: "no"
env_file:
- /home/reachableceo/.creds/beszel.env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- BESZEL_URL=${BESZEL_URL}
- BESZEL_USERNAME=${BESZEL_USERNAME}
- BESZEL_PASSWORD=${BESZEL_PASSWORD}
profiles: profiles:
- ops - ops
+8
View File
@@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' | grep -
done done
fi fi
# Load credentials from central store
CREDS_FILE="/home/reachableceo/.creds/beszel.env"
if [ -f "$CREDS_FILE" ]; then
set -a
. "$CREDS_FILE"
set +a
fi
# Start MCP server with explicit name and environment # Start MCP server with explicit name and environment
exec docker run -i --rm --name "${CONTAINER_NAME}" \ exec docker run -i --rm --name "${CONTAINER_NAME}" \
-e BESZEL_URL="${BESZEL_URL}" \ -e BESZEL_URL="${BESZEL_URL}" \
+8
View File
@@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' 2>/dev/n
done done
fi fi
# Load credentials from central store
CREDS_FILE="/home/reachableceo/.creds/discourse.env"
if [ -f "$CREDS_FILE" ]; then
set -a
. "$CREDS_FILE"
set +a
fi
# Start MCP server with explicit name and environment variables # Start MCP server with explicit name and environment variables
exec docker run -i --rm --name "${CONTAINER_NAME}" \ exec docker run -i --rm --name "${CONTAINER_NAME}" \
-e "DISCOURSE_URL=${DISCOURSE_URL:-}" \ -e "DISCOURSE_URL=${DISCOURSE_URL:-}" \
+8
View File
@@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' 2>/dev/n
done done
fi fi
# Load credentials from central store
CREDS_FILE="/home/reachableceo/.creds/redmine.env"
if [ -f "$CREDS_FILE" ]; then
set -a
. "$CREDS_FILE"
set +a
fi
# Start MCP server with explicit name and environment variables # Start MCP server with explicit name and environment variables
exec docker run -i --rm --name "${CONTAINER_NAME}" \ exec docker run -i --rm --name "${CONTAINER_NAME}" \
-e "REDMINE_URL=${REDMINE_URL:-}" \ -e "REDMINE_URL=${REDMINE_URL:-}" \
+6
View File
@@ -0,0 +1,6 @@
FROM node:22-slim
ARG BW_CLI_VERSION=2026.7.0
RUN npm install -g @bitwarden/cli@${BW_CLI_VERSION} && npm cache clean --force
ENTRYPOINT ["bw"]
+87
View File
@@ -0,0 +1,87 @@
# KNELCredsManager
Centralized credential management for KNEL infrastructure. Stores service
credentials on disk in `~/.creds/` and provides a containerized Bitwarden CLI
for future migration to a password manager.
## Credential store layout
All credentials live in `~/.creds/` as flat `.env` files, one per service:
```
~/.creds/
├── beszel.env # Beszel monitoring (hub URL set, auth TBD — see #406)
├── discourse.env # Discourse forum API keys + admin key
├── phpipam.env # phpIPAM app_id + app_code
├── redmine.env # Redmine REST API key
├── technitium.env # Technitium DNS API key + admin password
└── uptime-kuma.env # Uptime Kuma push/API key
```
Permissions: directory `700`, files `600` (owner read/write only).
### Consumers
All credential consumers source from `~/.creds/`:
| Service | Wrapper / consumer | Mechanism |
|---|---|---|
| Redmine | `redmine-cli` container (`tooling-cli/redmine/`) | `docker run --env-file ~/.creds/redmine.env` |
| Redmine MCP | `mcp-redmine-wrapper.sh` | `set -a; . ~/.creds/redmine.env; set +a` |
| Discourse | `discourse-cli` container (`tooling-cli/discourse/`) | `docker run --env-file ~/.creds/discourse.env` |
| Discourse MCP | `mcp-discourse-wrapper.sh` | `set -a; . ~/.creds/discourse.env; set +a` |
| Beszel MCP | `mcp-beszel-wrapper.sh` | `set -a; . ~/.creds/beszel.env; set +a` |
| Uptime Kuma | (no active consumer yet) | Direct env reference |
| Technitium | (no active consumer yet) | Direct env reference |
| phpIPAM | (no active consumer yet) | Direct env reference |
## Bitwarden CLI
The Bitwarden CLI runs in a pinned Docker container — no host installation
required. Built from this directory's Dockerfile.
### Build
```bash
docker build -t reachableceo-bw-cli:2026.7.0 .
```
### Wrapper
```bash
# Symlink the wrapper onto PATH
ln -sf ~/projects/KNEL-AIMiddleware/tooling-cli/KNELCredsManager/scripts/bw ~/.local/bin/bw
```
### Usage
```bash
bw login # interactive first-time login
export BW_SESSION=$(bw unlock --raw) # unlock and capture session
bw sync # sync vault
bw list items # list all vault items
bw get item <name> # get a specific item
bw status # check auth/session status
```
Session state persists in `~/.local/share/bw-cli/` across container runs.
### MCP integration (machine-to-machine)
For agent automation without interactive login, the KNEL-AIMiddleware fleet
includes `mcp-bitwarden-wrapper.sh` which uses `BITWARDEN_CLIENT_ID` /
`BITWARDEN_CLIENT_SECRET` / `BITWARDEN_PASSWORD` env vars (machine account
auth). That is a separate integration from this CLI wrapper.
## Build arguments
| Arg | Default | Description |
|---|---|---|
| `BW_CLI_VERSION` | `2026.7.0` | Pinned @bitwarden/cli npm version |
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `BW_CLI_IMAGE` | `reachableceo-bw-cli:2026.7.0` | Override image tag |
| `BW_SESSION` | (unset) | Session key from `bw unlock` |
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# bw — Bitwarden CLI wrapper (Docker containerized, host stays clean).
#
# Usage:
# bw login (interactive — prompts for email/master password/2FA)
# bw unlock (interactive — prints export BW_SESSION=... line)
# bw unlock --raw (prints only the session key, for scripting)
# bw list items
# bw get item <name-or-id>
# bw sync
# bw status
#
# Session management:
# After `bw unlock`, capture the session key:
# export BW_SESSION=$(bw unlock --raw)
# The wrapper passes BW_SESSION through automatically if already set.
#
# Data persistence:
# BW data.json lives at ~/.local/share/bw-cli/ mounted into the container,
# so login state persists across invocations.
set -euo pipefail
IMAGE="${BW_CLI_IMAGE:-reachableceo-bw-cli:2026.7.0}"
DATA_DIR="${HOME}/.local/share/bw-cli"
mkdir -p "$DATA_DIR"
# Detect TTY for interactive commands (login, unlock)
INTERACTIVE=""
if [ -t 0 ] && [ -t 1 ]; then
INTERACTIVE="-it"
fi
# Pass BW_SESSION through if set
SESSION_ARGS=()
if [ -n "${BW_SESSION:-}" ]; then
SESSION_ARGS+=( -e "BW_SESSION=${BW_SESSION}" )
fi
CONTAINER_NAME="reachableceo-bw-cli-$(date +%s)"
exec docker run --rm $INTERACTIVE \
--user "$(id -u):$(id -g)" \
--name "$CONTAINER_NAME" \
-e HOME=/home/bw \
-v "${DATA_DIR}:/home/bw/.config" \
"${SESSION_ARGS[@]}" \
"$IMAGE" "$@"
+8
View File
@@ -0,0 +1,8 @@
# Secrets - never commit
.env
# OS / editor cruft
.DS_Store
*.swp
*.swo
*~
+346
View File
@@ -0,0 +1,346 @@
# AGENTS.md — Discourse CLI tooling
This directory holds the `discourse-cli` Docker container — a thin Python CLI
over the Discourse REST API. Every Crush session that works on or with this
tool should read this file first: how to invoke it, what commands exist, what
gotchas to avoid, and the patterns to follow for common forum operations.
## Connection & invocation
Invoke the real container with `docker run`. Credentials come from the
centralized credential store (see `tooling-cli/KNELCredsManager`):
```bash
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest <command>
```
There is intentionally **no `bin/` wrapper** — invoke the real container, as
the project-wide rules require. For readability in an interactive session you
may define a throwaway shell alias (do not commit one):
```bash
alias discourse='docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest'
```
The examples below write `discourse <cmd>` for brevity; expand it to the full
`docker run` line (or set the alias) before running.
| Item | Value |
|------|-------|
| Instance | `https://community.turnsys.com` |
| API user | Charles N Wyble (id 2, username `reachableceo`, trust level 4) |
| Admin? | **No** — trust 4 (Leader) but not staff/admin. Admin-only endpoints will 403. |
| Credentials | `~/.creds/discourse.env` (`DISCOURSE_URL`, `DISCOURSE_API_KEY`, `DISCOURSE_API_USERNAME`) |
| CLI image | `git.knownelement.com/reachableceo/discourse-cli:latest` |
| Source | `src/discourse_cli.py` in this directory |
To use a locally built image instead of the registry image, either set
`DISCOURSE_CLI_IMAGE` or swap the tag for `kneldevstack-aimiddleware-discourse-cli:latest`.
## Quick start
```bash
# Connection sanity check (run this first in any session):
discourse whoami
# List categories:
discourse categories
# Latest topics (or in a category):
discourse ls
discourse ls -c general
# Look at a topic:
discourse show 42
```
## Command reference
| Command | Description |
|---------|-------------|
| `whoami` | Authenticated user + connection test. |
| `categories` | List categories (id, slug, topic count, name). |
| `cat-info <id\|slug>` | Show details of a single category. |
| `topics` (`ls`) | List topics (latest, or filtered). Filters below. |
| `show <topic_id>` | Full topic detail incl. all posts (HTML stripped to text). |
| `create` | Create a new topic (first post). |
| `reply <topic_id>` | Reply to a topic. |
| `update <post_id>` | Edit an existing post's body. |
| `delete <post_id>` | Delete a post. |
| `search <query>` | Search the forum. |
| `notifications` | List your notifications. |
### `topics` / `ls` filters
- `-c, --category <id|slug>` — only topics in a category
- `-n, --new` — only new (to you) topics
- `-u, --unread` — only unread topics
- `-p, --page <n>` — page number (default 0)
### `create` options
- `-t, --title` **required**
- `-b, --body` **required** (raw markdown/text — this becomes the first post)
- `-c, --category <id|slug>`
- `--tags` — comma-separated tag list
### `reply` options
- `topic_id` (positional, **required**) — the topic to reply in
- `-b, --body` **required** (raw markdown/text)
- `-r, --reply-to <post_number>` — reply to a specific **post number** (not id)
### `update` / `delete`
- Both operate on a **post id** (positional, required), not a topic id or
post number. Find the post id via `show` or the raw API (see gotcha below).
- `update -b, --body` **required** (new raw body).
### `search`
- `<query>` (positional, required)
- `-p, --page <n>` — page number (default 1)
### `notifications`
- `-l, --limit <n>` — max results (default 20)
## Key categories
| ID | Slug | Name | Notes |
|----|------|------|-------|
| 4 | `general` | General | Open discussion |
| 23 | `reachableceo` | ReachableCEO | Personal |
| 6 | `chiefoperationsandfinanceofficer` | ChiefOperationsOfficer | COO seat — parent for VP subcategories |
| 3 | `staff` | Staff | May be restricted |
| 74 | `vp-techops` | VP TechOps | ChiefOperationsOfficer (6) — 11 wiki topics migrated from PFVCluster |
| 75 | `vp-compliance` | VP Compliance | ChiefOperationsOfficer (6) — awaiting content |
| 76 | `board` | Board | (top-level, future) |
Run `discourse categories` for the full, current list.
### Key topics (VP TechOps — category 74)
Eleven wiki topics migrated from PFVCluster (Discourse is source of truth):
| Topic | Title | Pattern |
|-------|-------|---------|
| 296 | PFVCluster Project Overview | Pinned wiki |
| 297 | Operations Status | Pinned wiki (updated in place) |
| 298 | Infrastructure Audit Log | Wiki index + dated replies |
| 299 | Network Topology | Wiki |
| 300 | Storage Architecture | Wiki |
| 301 | Data Center Infrastructure | Wiki |
| 302 | Automation and Provisioning | Wiki |
| 303 | Security Architecture and Hardening | Wiki |
| 304 | Proxmox Fleet Reference | Wiki + replies |
| 305 | Kubernetes Platform | Wiki + replies |
| 306 | DNS and DHCP Services | Wiki + replies |
## Gotcha: post id vs post number
Discourse distinguishes a **post number** (1, 2, 3... within a topic — the
first post is always #1) from a **post id** (a globally unique integer).
- `reply -r` takes a **post number**.
- `update` / `delete` take a **post id**.
`show` prints headers like `--- #3 [3] author ...` where the value in brackets
is the post number (here they often coincide for simple topics, but they are
**not** the same thing). To reliably get a post's **id**, either read the raw
JSON via the escape hatch below, or note that `create`/`reply` print the id
of the post they just made (`Posted reply #<num> (id=<id>)`).
## Gotcha: not an admin (user key)
The default API user is trust level 4 (Leader) but **not** an admin/staff
member. With the user-level key:
- **Cannot** create categories, set wiki posts, configure site settings, or
access `/admin/...` endpoints (all 403).
- **Can** create topics, reply, edit own posts, search, list notifications.
For admin operations (category creation, wiki flagging, docs plugin config),
an **admin-scoped API key** is needed. Set it via `DISCOURSE_ADMIN_KEY` in the
credential file and pass it explicitly in raw `requests` calls.
Things that will fail or be restricted without admin:
- Deleting other users' posts.
- Creating topics in staff-only or restricted categories.
- Moving/merging/recategorizing topics.
- Setting the wiki flag on posts.
If an operation 403s, that's expected — surface it to the user rather than
retrying.
## Gotcha: bulk operations & raw API
For anything beyond a single `create`/`reply`/`update`, or to read fields the
CLI doesn't print (e.g. exact post ids, category permissions, user lists),
run Python directly inside the container with `requests`. The CLI uses raw
`requests` against the Discourse REST API (no heavyweight SDK).
Pattern (mount a script and run it in the same image):
```bash
cat > /tmp/script.py <<'PY'
import os, requests
URL = os.environ["DISCOURSE_URL"].rstrip("/")
H = {
"Api-Key": os.environ["DISCOURSE_API_KEY"],
"Api-Username": os.environ["DISCOURSE_API_USERNAME"],
"Accept": "application/json",
}
# Example: get post ids for topic 42
r = requests.get(f"{URL}/t/42.json", headers=H, timeout=30)
r.raise_for_status()
for p in r.json()["post_stream"]["posts"]:
print(p["post_number"], "id=", p["id"], "by", p["username"])
PY
docker run --rm --env-file ~/.creds/discourse.env \
--entrypoint python \
-v /tmp/script.py:/tmp/script.py \
git.knownelement.com/reachableceo/discourse-cli:latest \
/tmp/script.py
```
Useful raw endpoints:
- `GET /t/<topic_id>.json` — full topic incl. `post_stream.posts[]` (each has
`id`, `post_number`, `username`, `cooked`, `raw`).
- `GET /categories.json` — all categories.
- `GET /c/<cat_id>.json` — topics in a category.
- `POST /posts.json` — create topic (`title`+`raw`+`category`) or reply
(`topic_id`+`raw`).
- `PUT /posts/<post_id>.json` — edit (`{"post":{"raw":"..."}}`).
- `DELETE /posts/<post_id>.json` — delete.
- `GET /search.json?q=<query>` — search.
- `GET /notifications.json` — your notifications.
## Patterns
### Pattern: review & respond to a topic
1. `discourse show <topic_id>` — read the topic and all replies.
2. Identify the post you're responding to; note its **post number** (for
`-r`) and the overall context.
3. Draft a reply. `create`/`reply` take **raw markdown** — links, lists,
code fences all work.
4. `discourse reply <topic_id> -b "your markdown" [-r <post_number>]`.
5. Verify with `discourse show <topic_id>`.
### Pattern: before you act
- Always `discourse show <topic_id>` before replying/editing — confirm
the current state so you don't duplicate or contradict prior posts.
- **Never delete a post** unless the user explicitly asks.
- Prefer replying over editing someone else's post (and editing others'
posts will likely 403 anyway as a non-admin).
- When unsure which category to post in, `discourse categories` and pick
the closest match, or ask the user.
### Pattern: posting conventions
- Bodies are **raw markdown** — Discourse renders them. Use fenced code
blocks for commands/output, headings, tables, and bullet lists freely.
- Keep titles concise and descriptive.
- Use tags where the category supports them (`--tags a,b,c`).
### Pattern: wiki topics (living documents)
Wiki topics are the core anti-sprawl primitive. A wiki topic's **first post
is editable by anyone** with permission (not just the original author), and
Discourse preserves the full edit history automatically.
When to use a wiki topic:
- **Living references** — inventories, host lists, network topology, storage
maps. Updated in place as facts change.
- **Collaborative documents** — policies, design docs, specs where multiple
agents/humans contribute. First post = the document; replies = discussion.
- **Status/index pages** — operations status, documentation indexes.
When NOT to use a wiki topic:
- **Discussion threads** — regular topics where each reply is a distinct
voice. Wiki-editing the first post would destroy the conversation.
- **Point-in-time reports** — these go as dated replies inside a wiki
"log" topic (see audit pattern below).
To mark a post as wiki via the API (requires admin/moderator):
```python
requests.put(f"{URL}/posts/{post_id}.json", headers=H, json={"wiki": True})
```
### Pattern: post lifecycle (comment vs replace vs new)
The golden rule: **never create a new topic for an update to existing
knowledge.** One wiki topic per subsystem, updated in place.
| Change type | Action | Why |
|-------------|--------|-----|
| Fact update (new VM, IP changed) | Edit the wiki post in place | Edit history preserves old state |
| New snapshot (audit, drift report) | New reply in the existing log topic | One topic accumulates history |
| Question/discussion about content | Reply to the relevant post | Threaded, doesn't mutate the doc |
| Major restructure | Edit wiki + reply noting why | Edit log = what; reply = why |
### Pattern: audit/snapshot logs
Point-in-time reports (audits, drift reports, status snapshots) do NOT each
get their own topic. Instead, create ONE wiki "log" topic per domain and
append each snapshot as a dated reply:
```
[Wiki post #1] Index table (date | scope | link | superseded-by)
+ latest snapshot summary
[Reply #2] Audit 2026-07-29 — full content
[Reply #3] Audit 2026-07-30 — full content (supersedes #2)
[Reply #4] Audit 2026-08-05 — full content (current)
```
When a new snapshot arrives: add a reply with full content, then edit the
wiki first post to point at the latest reply as "current."
### Pattern: tag taxonomy
Tags are cross-cutting classifiers that prevent category multiplication:
| Tag | Meaning |
|-----|---------|
| `reference` | Living reference doc (inventories, topology, host lists) |
| `runbook` | Operational procedure (deploy, recover, configure) |
| `architecture` | Design doc, system architecture, capacity model |
| `policy` | Naming conventions, security policies, standards |
| `decision` | Resolved decision (ADR, distro choice, analysis outcome) |
| `audit` | Point-in-time snapshot |
| `security` | Security/hardening topic |
### Pattern: category taxonomy
Org-based hierarchy mirroring Known Element's VP seats:
```
ChiefOperationsOfficer (id 6)
├── vp-techops — infrastructure, network, compute, security ops
└── vp-compliance — frameworks, evidence, audit response
Board (future)
```
Security operations (SecOps) lives under vp-techops (security is operational).
Compliance covers frameworks (CMMC/STIG), evidence, and audit response.
## Git workflow
### Atomic commits
Each commit is **one logical change** — one feature, one fix, one doc update.
If you're tempted to write "and also..." in a commit message, that's a sign
to split it into two commits. Stage precisely (`git add <file>`, not
`git add -A`) so unrelated changes don't get bundled.
### Conventional commit messages
Use the [Conventional Commits](https://www.conventionalcommits.org/) format:
```
<type>(<optional scope>): <imperative subject>
<optional body — why, not what>
```
Types used in this repo: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
Rules:
- Subject line **under 72 chars**, lowercase, imperative mood.
- No period at end of subject.
- Body wrapped at 72 chars, explains **why** the change exists.
### Commit cadence
- **Commit early and often.** Don't accumulate a pile of unrelated changes.
- Commit **without asking** — if you made a coherent change, commit it.
- Every task (topic review, reply, script addition, doc update) ends with
the relevant files committed.
- Run `git status` before committing to stage only what belongs together.
@@ -13,20 +13,22 @@ the Discourse REST API. A small Python CLI built on `requests`.
## Configuration ## Configuration
Copy the env template and fill in real values (the `.env` is gitignored): Credentials live in the **centralized credential store** at
`~/.creds/discourse.env` (see `tooling-cli/KNELCredsManager`). It holds:
```bash
cp .env.example .env
# then edit .env:
# DISCOURSE_URL=https://discourse.example.com
# DISCOURSE_API_KEY=abc123...
# DISCOURSE_API_USERNAME=your-username
``` ```
DISCOURSE_URL=https://discourse.example.com
DISCOURSE_API_KEY=abc123...
DISCOURSE_API_USERNAME=your-username
```
Use `.env.example` in this directory as a template if you need to create one.
Permissions: directory `700`, the env file `600` (owner read/write only).
## Build ## Build
```bash ```bash
docker build -t kneldevstack-aimiddleware-discourse-cli . docker build -t kneldevstack-aimiddleware-discourse-cli:latest .
``` ```
A prebuilt image is also available in the Gitea registry: A prebuilt image is also available in the Gitea registry:
@@ -37,16 +39,29 @@ git.knownelement.com/reachableceo/discourse-cli:latest
## Usage ## Usage
### Via docker directly (from this directory) Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash ```bash
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli whoami docker run --rm --env-file ~/.creds/discourse.env \
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli ls git.knownelement.com/reachableceo/discourse-cli:latest whoami
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli show 42
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli create -c 5 -t "Hello" -b "Body text" docker run --rm --env-file ~/.creds/discourse.env \
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42 -b "Nice point" git.knownelement.com/reachableceo/discourse-cli:latest ls
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest show 42
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest create -c 5 -t "Hello" -b "Body text"
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest reply 42 -b "Nice point"
``` ```
To use a locally built image instead of the registry image, either set
`DISCOURSE_CLI_IMAGE` or swap the image tag for `kneldevstack-aimiddleware-discourse-cli:latest`.
## Commands ## Commands
| Command | Description | | Command | Description |
@@ -59,7 +74,7 @@ docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42
| `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). | | `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). |
| `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). | | `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). |
| `update <post_id>` | Update a post (`--body`). | | `update <post_id>` | Update a post (`--body`). |
| `delete <post_id>` | Delete a post. | | `delete <post_id>` | Delete a post. |
| `search <query>` | Search the forum. | | `search <query>` | Search the forum. |
| `notifications` | List your notifications. | | `notifications` | List your notifications. |
@@ -89,3 +104,14 @@ docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42
| `DISCOURSE_URL` | yes | Base URL of the Discourse instance. | | `DISCOURSE_URL` | yes | Base URL of the Discourse instance. |
| `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). | | `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). |
| `DISCOURSE_API_USERNAME`| yes | Username the API key acts as. | | `DISCOURSE_API_USERNAME`| yes | Username the API key acts as. |
## Validation
```bash
./validate.sh
```
Runs a full live read/write cycle against the configured instance (credential
check, whoami, categories, topics, show, search, create+reply+update+delete
cleanup). Credentials are read from `~/.creds/discourse.env` by default;
override with `DISCOURSE_ENV_FILE`.
@@ -121,17 +121,22 @@ def cmd_whoami(args):
def cmd_categories(args): def cmd_categories(args):
api = _client() api = _client()
data = api.get("/categories.json") data = api.get("/categories.json", params={"include_subcategories": "true"})
cats = data.get("category_list", {}).get("categories", []) cats = data.get("category_list", {}).get("categories", [])
print(f"{'ID':<6} {'SLUG':<28} {'TOPICS':>7} NAME") print(f"{'ID':<6} {'SLUG':<36} {'TOPICS':>7} NAME")
print("-" * 80) print("-" * 90)
count = 0
for c in cats: for c in cats:
topic_count = c.get("topic_count", 0) topic_count = c.get("topic_count", 0)
print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<28} {topic_count:>6} {c.get('name','')}") print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<36} {topic_count:>6} {c.get('name','')}")
subcats = [] count += 1
for c in cats: for s in c.get("subcategory_list") or []:
subcats.extend(c.get("subcategory_ids", []) or []) sub_count = s.get("topic_count", 0)
print(f"\n{len(cats)} category(ies)") slug = " " + str(s.get("slug", ""))
name = " " + s.get("name", "")
print(f"{s.get('id','?'):<6} {slug:<36} {sub_count:>6} {name}")
count += 1
print(f"\n{count} category(ies)")
return 0 return 0
@@ -314,7 +319,7 @@ def _resolve_category(api, ref):
"""Resolve a category id, slug, or name to a numeric id.""" """Resolve a category id, slug, or name to a numeric id."""
if str(ref).isdigit(): if str(ref).isdigit():
return int(ref) return int(ref)
data = api.get("/categories.json") data = api.get("/categories.json", params={"include_subcategories": "true"})
cats = data.get("category_list", {}).get("categories", []) cats = data.get("category_list", {}).get("categories", [])
needle = str(ref).strip().lower() needle = str(ref).strip().lower()
for c in cats: for c in cats:
@@ -322,6 +327,11 @@ def _resolve_category(api, ref):
return c["id"] return c["id"]
if str(c.get("name", "")).lower() == needle: if str(c.get("name", "")).lower() == needle:
return c["id"] return c["id"]
for s in c.get("subcategory_list") or []:
if str(s.get("slug", "")).lower() == needle:
return s["id"]
if str(s.get("name", "")).lower() == needle:
return s["id"]
_err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.") _err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.")
@@ -2,7 +2,7 @@
# discourse-cli validation script. # discourse-cli validation script.
# Runs everything inside containers so no host tooling (curl, etc.) is needed. # Runs everything inside containers so no host tooling (curl, etc.) is needed.
# #
# Usage: ./validate.sh (reads .env from the discourse-cli directory) # Usage: ./validate.sh
# #
# Exit codes: 0 = all checks passed, 1 = one or more checks failed # Exit codes: 0 = all checks passed, 1 = one or more checks failed
set -eu set -eu
@@ -10,13 +10,18 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
# Ensure .env exists # Credentials live in the centralized ~/.creds/ store (see KNELCredsManager).
if [ ! -f .env ]; then # Override with DISCOURSE_ENV_FILE if you need a different location.
echo "FAIL: .env not found. Copy .env.example to .env and fill in real values." ENV_FILE="${DISCOURSE_ENV_FILE:-$HOME/.creds/discourse.env}"
# Ensure env file exists
if [ ! -f "$ENV_FILE" ]; then
echo "FAIL: env file not found: $ENV_FILE"
echo " Set DISCOURSE_ENV_FILE or create it in ~/.creds/discourse.env"
exit 1 exit 1
fi fi
IMAGE="${DISCOURSE_CLI_IMAGE:-kneldevstack-aimiddleware-discourse-cli}" IMAGE="${DISCOURSE_CLI_IMAGE:-git.knownelement.com/reachableceo/discourse-cli:latest}"
PASS=0 PASS=0
FAIL=0 FAIL=0
TOTAL=0 TOTAL=0
@@ -35,10 +40,10 @@ echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [0] Credential check (raw HTTP via containerized curl) ---" echo "--- [0] Credential check (raw HTTP via containerized curl) ---"
# Load env vars from .env for the raw test # Load env vars from the creds file for the raw test
DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' .env | cut -d= -f2-) DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' "$ENV_FILE" | cut -d= -f2-)
DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' .env | cut -d= -f2-) DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' "$ENV_FILE" | cut -d= -f2-)
DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' .env | cut -d= -f2-) DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' "$ENV_FILE" | cut -d= -f2-)
HTTP_CODE=$(docker run --rm curlimages/curl:latest \ HTTP_CODE=$(docker run --rm curlimages/curl:latest \
-s -o /dev/null -w '%{http_code}' \ -s -o /dev/null -w '%{http_code}' \
@@ -67,7 +72,7 @@ echo ""
# 1. whoami (live connection test) # 1. whoami (live connection test)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [1] whoami ---" echo "--- [1] whoami ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" whoami 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" whoami 2>&1) || true
if echo "$OUT" | grep -qE '^username:'; then if echo "$OUT" | grep -qE '^username:'; then
ok "whoami returned user info" ok "whoami returned user info"
echo " $OUT" | head -5 echo " $OUT" | head -5
@@ -81,7 +86,7 @@ echo ""
# 2. categories (live read) # 2. categories (live read)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [2] categories ---" echo "--- [2] categories ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" categories 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" categories 2>&1) || true
if echo "$OUT" | grep -qE '^[0-9]+ category'; then if echo "$OUT" | grep -qE '^[0-9]+ category'; then
ok "categories returned data" ok "categories returned data"
echo " $OUT" | head -6 echo " $OUT" | head -6
@@ -98,7 +103,7 @@ echo ""
# 3. topics / ls (live read) # 3. topics / ls (live read)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [3] topics (latest) ---" echo "--- [3] topics (latest) ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" ls 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" ls 2>&1) || true
if echo "$OUT" | grep -qE 'topic\(s\)'; then if echo "$OUT" | grep -qE 'topic\(s\)'; then
ok "topics list returned data" ok "topics list returned data"
echo " $OUT" | head -6 echo " $OUT" | head -6
@@ -115,7 +120,7 @@ echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
if [ -n "${TOPIC_ID:-}" ]; then if [ -n "${TOPIC_ID:-}" ]; then
echo "--- [4] show topic ${TOPIC_ID} ---" echo "--- [4] show topic ${TOPIC_ID} ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true
if echo "$OUT" | grep -qE '^#'; then if echo "$OUT" | grep -qE '^#'; then
ok "show topic ${TOPIC_ID} returned content" ok "show topic ${TOPIC_ID} returned content"
echo " $OUT" | head -8 echo " $OUT" | head -8
@@ -132,7 +137,7 @@ echo ""
# 5. search (live read) # 5. search (live read)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [5] search ---" echo "--- [5] search ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" search "test" 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "test" 2>&1) || true
if echo "$OUT" | grep -qE 'result\(s\)'; then if echo "$OUT" | grep -qE 'result\(s\)'; then
ok "search returned results" ok "search returned results"
echo " $OUT" | head -5 echo " $OUT" | head -5
@@ -147,7 +152,7 @@ echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [6] create + reply + update + delete (write cycle) ---" echo "--- [6] create + reply + update + delete (write cycle) ---"
CREATE_OUT=$(docker run --rm --env-file .env "${IMAGE}" create \ CREATE_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" create \
${CAT_ID:+-c "$CAT_ID"} \ ${CAT_ID:+-c "$CAT_ID"} \
-t "discourse-cli validation test $(date +%s)" \ -t "discourse-cli validation test $(date +%s)" \
-b "This is an automated validation test post. It will be cleaned up shortly after creation." \ -b "This is an automated validation test post. It will be cleaned up shortly after creation." \
@@ -159,7 +164,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then
echo " $CREATE_OUT" echo " $CREATE_OUT"
# reply # reply
REPLY_OUT=$(docker run --rm --env-file .env "${IMAGE}" reply "${NEW_TOPIC_ID}" \ REPLY_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" reply "${NEW_TOPIC_ID}" \
-b "Validation reply test." 2>&1) || true -b "Validation reply test." 2>&1) || true
POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+') POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+')
if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then
@@ -172,7 +177,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then
# update # update
if [ -n "${POST_ID:-}" ]; then if [ -n "${POST_ID:-}" ]; then
UPD_OUT=$(docker run --rm --env-file .env "${IMAGE}" update "${POST_ID}" \ UPD_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" update "${POST_ID}" \
-b "Updated validation reply test." 2>&1) || true -b "Updated validation reply test." 2>&1) || true
if echo "$UPD_OUT" | grep -qE 'Updated post'; then if echo "$UPD_OUT" | grep -qE 'Updated post'; then
ok "update post ${POST_ID} succeeded" ok "update post ${POST_ID} succeeded"
@@ -182,7 +187,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then
fi fi
# delete # delete
DEL_OUT=$(docker run --rm --env-file .env "${IMAGE}" delete "${POST_ID}" 2>&1) || true DEL_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" delete "${POST_ID}" 2>&1) || true
if echo "$DEL_OUT" | grep -qE 'Deleted post'; then if echo "$DEL_OUT" | grep -qE 'Deleted post'; then
ok "delete post ${POST_ID} succeeded" ok "delete post ${POST_ID} succeeded"
else else
@@ -197,13 +202,9 @@ fi
echo "" echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
# Summary # Results
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "============================================" echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed" echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================" echo "============================================"
[ "$FAIL" -eq 0 ]
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0
+5
View File
@@ -0,0 +1,5 @@
# Technitium DNS connection details
# Copy this file to .env and fill in real values. The .env file is gitignored.
TECHNITIUM_URL=http://pfv-netinfra-01:5380
TECHNITIUM_DNS_TOKEN=your-dns-api-token-here
TECHNITIUM_DNS_ZONE=knel.net
+8
View File
@@ -0,0 +1,8 @@
# Secrets - never commit
.env
# OS / editor cruft
.DS_Store
*.swp
*.swo
*~
+97
View File
@@ -0,0 +1,97 @@
# AGENTS.md — DNS CLI tooling
This directory holds the `dns-cli` Docker container — a bash CLI over the
Technitium DNS Server REST API. Every Crush session that works on or with
this tool should read this file first.
## Connection & invocation
Invoke the real container with `docker run`. Credentials come from the
centralized credential store (see `tooling-cli/KNELCredsManager`):
```bash
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest <command>
```
There is intentionally **no `bin/` wrapper** — invoke the real container, as
the project-wide rules require. For readability in an interactive session you
may define a throwaway shell alias (do not commit one):
```bash
alias dns='docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest'
```
The examples below write `dns <cmd>` for brevity; expand it to the full
`docker run` line (or set the alias) before running.
| Item | Value |
|------|-------|
| Instance | `http://pfv-netinfra-01.knel.net:5380` |
| Default zone | `knel.net` |
| Credentials | `~/.creds/technitium.env` (`TECHNITIUM_URL`, `TECHNITIUM_DNS_TOKEN`, `TECHNITIUM_DNS_ZONE`) |
| CLI image | `git.knownelement.com/reachableceo/dns-cli:latest` |
| Source | `src/dns_cli.sh` in this directory |
## Quick start
```bash
# List all zones:
dns zones
# List records in the default zone:
dns list
# Search for a record:
dns search ns1
# Resolve a name:
dns get pfv-netinfra-01
```
## Command reference
| Command | Description |
|---------|-------------|
| `zones` | List all zones. |
| `list [zone]` | List records in a zone (default: `TECHNITIUM_DNS_ZONE`). |
| `get <name>` | Resolve a record via `dig` (A/AAAA). |
| `add <name> <ip> [ttl]` | Add an A record with auto-PTR (default TTL 3600). |
| `delete <name> <ip>` | Delete an A record. |
| `flush` | Flush the DNS cache. |
| `search <pattern>` | Search records by name pattern (case-insensitive). |
## Patterns
### Pattern: add a new DNS record
1. `dns search <hostname>` — check if the name already exists.
2. `dns add <hostname> <ip>` — creates the A record with auto-PTR.
3. `dns get <hostname>` — verify resolution.
4. `dns flush` — flush cache so the new record is immediately resolvable.
### Pattern: before you act
- Always `dns search <pattern>` or `dns list` before adding/deleting —
confirm current state so you don't create duplicates or delete the wrong
record.
- **Never delete a record** unless the user explicitly asks.
- PTR records are auto-created on `add` and auto-deleted on `delete`.
## Git workflow
### Atomic commits
Each commit is **one logical change**. Stage precisely (`git add <file>`,
not `git add -A`).
### Conventional commit messages
```
<type>(<optional scope>): <imperative subject>
<optional body — why, not what>
```
Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
- Subject line under 72 chars, lowercase, imperative mood.
- No period at end of subject.
- Body wrapped at 72 chars, explains **why** the change exists.
+9
View File
@@ -0,0 +1,9 @@
FROM alpine:3.20
RUN apk add --no-cache bash curl python3 bind-tools
COPY src/dns_cli.sh /usr/local/bin/dns-cli
RUN chmod +x /usr/local/bin/dns-cli
ENTRYPOINT ["dns-cli"]
CMD ["--help"]
+82
View File
@@ -0,0 +1,82 @@
# dns-cli
A Docker container that lets an AI agent (or a human) manage DNS records on a
[Technitium DNS Server](https://technitium.com/dns/) instance through its REST
API. A small bash CLI built on `curl` + `python3` + `dig`.
## Requirements
- Docker on the host.
- A Technitium DNS Server instance with the REST API enabled.
- A DNS API token (Technitium → Settings → API Token).
## Configuration
Credentials live in the **centralized credential store** at
`~/.creds/technitium.env` (see `tooling-cli/KNELCredsManager`). It holds:
```
TECHNITIUM_URL=http://pfv-netinfra-01:5380
TECHNITIUM_DNS_TOKEN=your-api-token
TECHNITIUM_DNS_ZONE=knel.net
```
Use `.env.example` in this directory as a template if you need to create one.
Permissions: directory `700`, the env file `600` (owner read/write only).
## Build
```bash
docker build -t git.knownelement.com/reachableceo/dns-cli:latest .
```
The image is also available in the Gitea registry:
```
git.knownelement.com/reachableceo/dns-cli:latest
```
## Usage
Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest zones
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest list
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest search ns1
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest add myhost 192.168.1.50
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest delete myhost 192.168.1.50
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest flush
```
## Commands
| Command | Description |
|---------|-------------|
| `zones` | List all zones. |
| `list [zone]` | List records in a zone (default: `TECHNITIUM_DNS_ZONE`). |
| `get <name>` | Resolve a record via `dig` (A/AAAA). |
| `add <name> <ip> [ttl]` | Add an A record with auto-PTR (default TTL 3600). |
| `delete <name> <ip>` | Delete an A record. |
| `flush` | Flush the DNS cache. |
| `search <pattern>` | Search records by name pattern (case-insensitive). |
## Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| `TECHNITIUM_URL` | yes | Base URL of the Technitium instance. |
| `TECHNITIUM_DNS_TOKEN` | yes | API token for the DNS REST API. |
| `TECHNITIUM_DNS_ZONE` | yes | Default zone for add/delete/search operations. |
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# dns-cli - CLI wrapper for Technitium DNS Server API
#
# Usage:
# dns-cli zones List all zones
# dns-cli list [zone] List records in a zone (default: $TECHNITIUM_DNS_ZONE)
# dns-cli get <name> Resolve a record (A/AAAA)
# dns-cli add <name> <ip> [ttl] Add A record (+ auto PTR)
# dns-cli delete <name> <ip> Delete A record
# dns-cli flush Flush DNS cache
# dns-cli search <pattern> Search records by name pattern
#
# Env (provided via --env-file ~/.creds/technitium.env):
# TECHNITIUM_URL Technitium base URL (required)
# TECHNITIUM_DNS_TOKEN API token (required)
# TECHNITIUM_DNS_ZONE Default zone (required)
set -euo pipefail
URL="${TECHNITIUM_URL:?TECHNITIUM_URL is required}"
TOKEN="${TECHNITIUM_DNS_TOKEN:?TECHNITIUM_DNS_TOKEN is required}"
ZONE="${TECHNITIUM_DNS_ZONE:?TECHNITIUM_DNS_ZONE is required}"
# API helper — adds Bearer auth, returns JSON
api() {
local method="$1" endpoint="$2"; shift 2
local args=()
for kv in "$@"; do
args+=(-d "$kv")
done
curl -s -H "Authorization: Bearer $TOKEN" -X "$method" "${args[@]}" \
"$URL/api/$endpoint" 2>/dev/null
}
# Subcommands
cmd_zones() {
api GET "zones/list" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for z in d.get('response',{}).get('zones',[]):
print(z['name'])
"
}
cmd_list() {
local zone="${1:-$ZONE}"
api GET "zones/records/get?domain=$zone&zone=$zone&listZone=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
recs = d.get('response',{}).get('records',[])
for r in sorted(recs, key=lambda x: (x['name'], x['type'])):
rd = r.get('rData',{})
ip = rd.get('ipAddress','')
val = ip or rd.get('nameServer','') or rd.get('primaryNameServer','') or str(rd)
print(f\"{r['name']:50s} {r['type']:6s} {r.get('ttl',''):6} {val}\")
"
}
cmd_get() {
local name="$1"
dig +short "$name" 2>/dev/null || true
}
cmd_add() {
local name="$1" ip="$2" ttl="${3:-3600}"
local fqdn="$name"
[[ "$fqdn" != *.* ]] && fqdn="$name.$ZONE"
api POST "zones/records/add" \
"zone=$ZONE" "domain=$fqdn" "type=A" "ttl=$ttl" "ipAddress=$ip" "ptr=true" "overwrite=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('status','?'), d.get('errorMessage',''))
"
}
cmd_delete() {
local name="$1" ip="$2"
local fqdn="$name"
[[ "$fqdn" != *.* ]] && fqdn="$name.$ZONE"
api POST "zones/records/delete" \
"zone=$ZONE" "domain=$fqdn" "type=A" "ipAddress=$ip" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('status','?'), d.get('errorMessage',''))
"
}
cmd_flush() {
api POST "cache/flush" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('status','?'))
"
}
cmd_search() {
local pattern="$1"
api GET "zones/records/get?domain=$ZONE&zone=$ZONE&listZone=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
recs = d.get('response',{}).get('records',[])
for r in sorted(recs, key=lambda x: x['name']):
if '$pattern' in r['name'].lower():
rd = r.get('rData',{})
ip = rd.get('ipAddress','')
val = ip or rd.get('nameServer','') or str(rd)
print(f\"{r['name']:50s} {r['type']:6s} {val}\")
"
}
# Main
case "${1:-}" in
zones) cmd_zones ;;
list) shift; cmd_list "${1:-}" ;;
get) shift; cmd_get "$1" ;;
add) shift; cmd_add "$@" ;;
delete) shift; cmd_delete "$@" ;;
flush) cmd_flush ;;
search) shift; cmd_search "$1" ;;
""|-h|--help|help)
sed -n '2,18p' "$0" >&2
exit 0
;;
*) echo "Unknown command: $1" >&2; exit 1 ;;
esac
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# dns-cli validation script.
# Runs a live read-only cycle against the configured Technitium instance.
#
# Usage: ./validate.sh
# Exit codes: 0 = all checks passed, 1 = one or more checks failed
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
ENV_FILE="${TECHNITIUM_ENV_FILE:-$HOME/.creds/technitium.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "FAIL: env file not found: $ENV_FILE"
echo " Set TECHNITIUM_ENV_FILE or create it in ~/.creds/technitium.env"
exit 1
fi
IMAGE="${DNS_CLI_IMAGE:-git.knownelement.com/reachableceo/dns-cli:latest}"
PASS=0
FAIL=0
TOTAL=0
ok() { TOTAL=$((TOTAL+1)); echo "PASS: $1"; PASS=$((PASS+1)); }
fail() { TOTAL=$((TOTAL+1)); echo "FAIL: $1"; FAIL=$((FAIL+1)); }
echo "============================================"
echo " dns-cli validation"
echo " image: ${IMAGE}"
echo "============================================"
echo ""
# ---------------------------------------------------------------- #
# 1. zones (live read)
# ---------------------------------------------------------------- #
echo "--- [1] zones ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" zones 2>&1) || true
if echo "$OUT" | grep -qE '\.'; then
ok "zones returned data"
echo "$OUT" | head -5
else
fail "zones did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 2. list (live read)
# ---------------------------------------------------------------- #
echo "--- [2] list ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" list 2>&1) || true
if echo "$OUT" | grep -qE 'A\s'; then
ok "list returned records"
echo "$OUT" | head -5
else
fail "list did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 3. search (live read)
# ---------------------------------------------------------------- #
echo "--- [3] search ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "ns" 2>&1) || true
if [ -n "$OUT" ]; then
ok "search returned results"
echo "$OUT" | head -3
else
fail "search returned no results"
fi
echo ""
# ---------------------------------------------------------------- #
# Results
# ---------------------------------------------------------------- #
echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================"
[ "$FAIL" -eq 0 ]
+8
View File
@@ -0,0 +1,8 @@
# Secrets - never commit
.env
# OS / editor cruft
.DS_Store
*.swp
*.swo
*~
+240
View File
@@ -0,0 +1,240 @@
# AGENTS.md — Redmine CLI tooling
This directory holds the `redmine-cli` Docker container — a thin Python CLI
over `python-redmine` for Redmine issue tracking. Every Crush session that
works on or with this tool should read this file first: how to invoke it,
what commands exist, what gotchas to avoid, and the patterns to follow for
common ticket operations.
## Connection & invocation
Invoke the real container with `docker run`. Credentials come from the
centralized credential store (see `tooling-cli/KNELCredsManager`):
```bash
docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest <command>
```
There is intentionally **no `bin/` wrapper** — invoke the real container, as
the project-wide rules require. For readability in an interactive session you
may define a throwaway shell alias (do not commit one):
```bash
alias redmine='docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest'
```
The examples below write `redmine <cmd>` for brevity; expand it to the full
`docker run` line (or set the alias) before running.
| Item | Value |
|------|-------|
| Instance | `https://projects.knownelement.com` |
| API user | Charles N (id 5, login `reachableceo`) |
| Credentials | `~/.creds/redmine.env` (`REDMINE_URL`, `REDMINE_API_KEY`) |
| CLI image | `git.knownelement.com/reachableceo/redmine-cli:latest` |
| Source | `src/redmine_cli.py` in this directory |
To use a locally built image instead of the registry image, either set
`REDMINE_CLI_IMAGE` or swap the tag for `kneldevstack-aimiddleware-redmine-cli:latest`.
## Quick start
```bash
# Connection sanity check (run this first in any session):
redmine whoami
# Look at a ticket:
redmine show 314
# Your queue:
redmine list --assigned-to-me
```
## Command reference
| Command | Description |
|---------|-------------|
| `whoami` | Authenticated user + connection test. |
| `projects` | List projects (id, identifier, name). |
| `statuses` | List issue statuses + which are "closed". |
| `list` (`ls`) | List issues. Filters below. |
| `show <id>` | Full issue detail incl. note history. |
| `create` | Create an issue. |
| `update <id>` | Update an issue (status, notes, assignee, done, ...). |
| `close <id>` | Close (first closed status, done ratio 100%). |
### `list` filters
- `-m, --assigned-to-me` — only issues assigned to you
- `-a, --assigned-to <id>` — filter by assignee user id
- `-p, --project <id|slug>` — filter by project
- `-s, --status <name|id|open|closed>` — filter by status
- `-l, --limit <n>` — max results (default 50)
- `--sort <spec>` — Redmine sort spec (default `priority:desc,updated_on:desc`)
### `create` options
- `-p, --project <id|slug>` **required**
- `-s, --subject` **required**
- `-d, --description`
- `-a, --assigned-to <id>`
- `-t, --tracker <id>`
- `--priority <id>`
- `--status <name|id>`
### `update` options
- `-s, --status <name|id>`
- `-n, --notes <text>`
- `-a, --assigned-to <id>`
- `--done-ratio <0-100>`
- `--subject`
- `--priority <id>`
## Key project / tracker / status IDs
### Most-used projects
| ID | Identifier | Name |
|----|------------|------|
| 55 | `technicaloperations` | Known Element Enterprises - Technology & Facility Services |
| 62 | `business-operations` | Known Element Enterprises - Business Services |
| 77 | `tsys-group` | TSYS Group |
Run `redmine projects` for the full list.
### Statuses
| ID | Name | Closed? |
|----|------|---------|
| 1 | New | no |
| 2 | In Progress | no |
| 3 | Resolved | yes |
| 4 | Feedback | no |
| 5 | Closed | yes |
| 6 | Rejected | no |
### Trackers
Tracker 3 = **Support** (the most common one in project 55). Inspect a
parent issue to inherit its exact tracker.
## Gotcha: creating subtasks
`redmine create` has **no `--parent` flag**. To create a subtask of an
existing issue you must use `python-redmine` directly inside the container
(via `--entrypoint python`).
Pattern (mount a script and run it in the same image):
```bash
cat > /tmp/script.py <<'PY'
import os
from redminelib import Redmine
rm = Redmine(os.environ["REDMINE_URL"].rstrip("/"), key=os.environ["REDMINE_API_KEY"])
issue = rm.issue.create(
project_id=55, # inherit from parent
tracker_id=3, # inherit from parent
priority_id=2, # inherit from parent
status_id=4, # Feedback = freshly created, awaiting work
assigned_to_id=5, # inherit from parent
parent_issue_id=314, # THE KEY FIELD
subject="Your subject",
description="Your description",
)
print(f"Created #{issue.id}")
PY
docker run --rm --env-file ~/.creds/redmine.env \
--entrypoint python \
-v /tmp/script.py:/tmp/script.py \
git.knownelement.com/reachableceo/redmine-cli:latest \
/tmp/script.py
```
**Before creating subtasks**, inspect the parent to inherit its attributes:
```bash
docker run --rm --env-file ~/.creds/redmine.env \
--entrypoint python \
git.knownelement.com/reachableceo/redmine-cli:latest -c "
import os
from redminelib import Redmine
rm = Redmine(os.environ['REDMINE_URL'].rstrip('/'), key=os.environ['REDMINE_API_KEY'])
i = rm.issue.get(PARENT_ID)
print('project_id:', i.project.id)
print('tracker_id:', i.tracker.id, i.tracker.name)
print('priority_id:', i.priority.id, i.priority.name)
print('assigned_to_id:', getattr(i.assigned_to,'id',None))
print('status_id:', i.status.id, i.status.name)
"
```
## Patterns
### Pattern: review & split a ticket
1. `redmine show <id>` — read the full ticket (description + history).
2. Identify the **distinct bodies of work**. Each should be independently
trackable and assignable.
3. Map out **dependencies** between the pieces (what blocks what).
4. Propose a split to the user as a table: proposed subject, scope, and
dependencies. Wait for approval before creating anything.
5. On approval: create subtasks (see "Gotcha: creating subtasks" above),
inheriting project/tracker/priority/assignee from the parent.
6. Update the parent's **description** to an index table of children +
recommended execution order. Add a note explaining the split.
7. Verify with `redmine show <parent_id>`.
### Pattern: ticket conventions
- **Subject prefix:** OAM-pool tickets use `OAM: <topic>`. Match the parent's
naming convention if one exists.
- **Description header:** always start with `Parent/umbrella: #<id> (<subject>).`
- **Scope section:** bullet list of concrete deliverables. Use `- [ ]` for
checklist items within a subtask.
- **Dependencies section:** list what the ticket depends on and what it blocks,
referencing ticket numbers once they exist.
- **Newly created children** go to status **Feedback (4)** so they're visible
but not yet "in progress".
- **Tables** in descriptions render in Redmine's Markdown pipeline (`| a | b |`).
### Pattern: bulk operations
For anything beyond a single `create`/`update`/`close`, write a Python script
and run it inside the container as shown in the subtask gotcha above. This
applies to: batch status changes, bulk ticket creation, inspecting parent
attributes, relationship wiring, etc.
### Pattern: before you act
- Always `redmine show <id>` before updating — confirm current status,
assignee, and existing notes so you don't clobber context.
- Never delete or close a ticket unless the user explicitly asks.
- When in doubt about project/tracker/priority, inherit from the parent or
ask the user.
## Git workflow
### Atomic commits
Each commit is **one logical change** — one feature, one fix, one doc update.
If you're tempted to write "and also..." in a commit message, that's a sign
to split it into two commits. Stage precisely (`git add <file>`, not
`git add -A`) so unrelated changes don't get bundled.
### Conventional commit messages
Use the [Conventional Commits](https://www.conventionalcommits.org/) format:
```
<type>(<optional scope>): <imperative subject>
<optional body — why, not what>
```
Types used in this repo: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
Rules:
- Subject line **under 72 chars**, lowercase, imperative mood.
- No period at end of subject.
- Body wrapped at 72 chars, explains **why** the change exists.
### Commit cadence
- **Commit early and often.** Don't accumulate a pile of unrelated changes.
- Commit **without asking** — if you made a coherent change, commit it.
- Every task (ticket review, split, script addition, doc update) ends with
the relevant files committed.
- Run `git status` before committing to stage only what belongs together.
@@ -15,19 +15,21 @@ wrapper.
## Configuration ## Configuration
Copy the env template and fill in real values (the `.env` is gitignored): Credentials live in the **centralized credential store** at
`~/.creds/redmine.env` (see `tooling-cli/KNELCredsManager`). It holds:
```bash
cp .env.example .env
# then edit .env:
# REDMINE_URL=https://your-redmine.example.com
# REDMINE_API_KEY=abc123...
``` ```
REDMINE_URL=https://your-redmine.example.com
REDMINE_API_KEY=abc123...
```
Use `.env.example` in this directory as a template if you need to create one.
Permissions: directory `700`, the env file `600` (owner read/write only).
## Build ## Build
```bash ```bash
docker build -t kneldevstack-aimiddleware-redmine-cli . docker build -t kneldevstack-aimiddleware-redmine-cli:latest .
``` ```
A prebuilt image is also available in the Gitea registry: A prebuilt image is also available in the Gitea registry:
@@ -38,21 +40,25 @@ git.knownelement.com/reachableceo/redmine-cli:latest
## Usage ## Usage
### Via the repo wrapper (loads `.env` automatically) Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash ```bash
bin/redmine whoami docker run --rm --env-file ~/.creds/redmine.env \
bin/redmine list --assigned-to-me git.knownelement.com/reachableceo/redmine-cli:latest whoami
bin/redmine show 123
bin/redmine close 123 --notes "work complete" docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest list --assigned-to-me
docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest show 123
docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest close 123 --notes "work complete"
``` ```
### Via docker directly (from the repo root) To use a locally built image instead of the registry image, either set
`REDMINE_CLI_IMAGE` or swap the image tag for `kneldevstack-aimiddleware-redmine-cli:latest`.
```bash
docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli whoami
docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli list --assigned-to-me
```
## Commands ## Commands
@@ -94,4 +100,3 @@ docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli list --ass
| ----------------- | -------- | ------------------------------------ | | ----------------- | -------- | ------------------------------------ |
| `REDMINE_URL` | yes | Base URL of the Redmine instance. | | `REDMINE_URL` | yes | Base URL of the Redmine instance. |
| `REDMINE_API_KEY` | yes | API key of the acting user. | | `REDMINE_API_KEY` | yes | API key of the acting user. |
| `REDMINE_CLI_IMAGE` | no | Override the image tag used by `bin/redmine` (default `git.knownelement.com/reachableceo/redmine-cli:latest`). |