16 Commits
Author SHA1 Message Date
jblu ebec893a85 Merge pull request 'Improve logging: named logger, rotation, richer format' (#62) from logging-improvement into main
test / komodo-deploy-script (push) Successful in 20s
ci / docker (push) Successful in 42s
Reviewed-on: #62
2026-10-27 00:31:29 -05:00
jblu 147758a1e8 Merge pull request 'ci: auto-deploy qbit-maid to Komodo after image push' (#63) from dev-komodo-deploy into main
ci / docker (push) Failing after 21s
test / komodo-deploy-script (push) Successful in 22s
Reviewed-on: #63
2026-10-27 00:31:12 -05:00
Jonathan Branan b2b8f6c10d fix: deterministic exit code for missing Komodo env vars
test / komodo-deploy-script (push) Successful in 16s
ci / docker (push) Successful in 48s
test / komodo-deploy-script (pull_request) Successful in 16s
':?' exits 1 under bash but 2 under dash (the Ubuntu runner's sh),
so the two missing-variable tests failed in CI while passing locally.
Replace with explicit checks so every failure path exits 1.
2026-10-27 00:16:51 -05:00
Jonathan Branan 003217a2b8 test: cover komodo-deploy.sh against a mock Komodo API
ci / docker (push) Successful in 21s
test / komodo-deploy-script (push) Failing after 21s
15 unittest cases exercising the real script end to end: target
selection, auth headers, request shape, success/failure, HTTP 401/400,
unreachable host, polling to completion, and timeout.

Runs in CI via .gitea/workflows/test.yml on push to main/dev* and on
pull requests. No network or live Komodo instance required.
2026-10-27 00:11:53 -05:00
Jonathan Branan 45908b8f6e ci: fix Komodo deploy step permission denied
ci / docker (push) Successful in 5m11s
core.fileMode is false in this repo, so the exec bit on
komodo-deploy.sh was not recorded and the runner hit
'Permission denied'. Mark the file executable in the index and
invoke it via 'sh' so the step no longer depends on the mode bit.
2026-10-26 23:50:28 -05:00
Jonathan Branan ef002b2fce ci: auto-deploy to Komodo stack after image push
ci / docker (push) Failing after 44s
Adds a shared .gitea/scripts/komodo-deploy.sh that calls the Komodo
/execute/DeployStack API after the image is pushed, then polls
/read/GetUpdate until the deploy completes.

A push webhook cannot do this correctly: it fires before the image
exists in the registry, so Komodo would redeploy the previous :latest.

- docker.yml deploys stack 'qbit-maid' (required)
- docker-dev.yml deploys vars.KOMODO_STACK_DEV, skipped when unset
2026-10-26 23:43:42 -05:00
Jonathan Branan f1489ba8a8 docs: plan for automatic Komodo deploys from Gitea Actions 2026-10-26 23:31:04 -05:00
Jonathan Branan ab30d69c7c Improve logging: named logger, rotation, richer format, remove use_log guards
- Add setup_logging() returning a named 'qbit-maid' logger with
  RotatingFileHandler (configurable size/backups) and a console handler
- Add log_torrent() helper for structured torrent context
- Replace basicConfig-based tor_log() with setup_logging()
- Remove redundant 'if self.use_log:' guards across qbit-maid.py,
  qlist.py, qprocess.py, qlogging.py (level filtering handles it)
- Document new logging options in config.toml.example and README
2026-10-26 22:10:35 -05:00
jblu 36d9692086 Merge pull request 'fixed crontab writing jobs multiple times' (#61) from dev-update-crontab into main
ci / docker (push) Successful in 38s
Reviewed-on: #61
2026-04-10 16:40:28 -05:00
jblu 4416ebd924 fixed crontab writing jobs multiple times
ci / docker (push) Successful in 41s
2026-04-10 16:19:36 -05:00
jblu acaf91a301 update readme
ci / docker (push) Successful in 39s
2026-04-09 12:32:59 -05:00
jblu 2213a0655d Merge pull request 'dev-migrate-to-crond' (#60) from dev-migrate-to-crond into main
ci / docker (push) Successful in 40s
Reviewed-on: #60
2026-04-08 20:52:28 -05:00
jblu 6248b7f721 update readme
ci / docker (push) Successful in 38s
2026-04-08 19:12:43 -05:00
jblu e1668dd42d migrated to crond 2026-04-08 19:10:04 -05:00
jblu d15cc8d95c updated healthchecks to support starting and stopping a job
continuous-integration/drone/push Build is passing
2024-06-03 20:32:57 -05:00
jblu 708d56fc98 Updated documentation
continuous-integration/drone/push Build is passing
2023-10-05 08:24:28 -05:00
16 changed files with 864 additions and 134 deletions

No files matched your search

-59
View File
@@ -1,59 +0,0 @@
kind: pipeline
name: default
steps:
- name: docker
image: plugins/docker
settings:
registry: git.jonb.io
dry_run: false
username: jblu
password:
from_secret: gittea_drone
repo: git.jonb.io/jblu/qbit-maid
tags:
- latest
when:
branch:
- main
event:
- push
- pull_request
- name: docker-test
image: plugins/docker
settings:
registry: git.jonb.io
dry_run: false
username: jblu
password:
from_secret: gittea_drone
repo: git.jonb.io/jblu/qbit-maid
tags:
- dev
when:
branch:
- dev*
event:
- push
- pull_request
- name: test-main
image: git.jonb.io/jblu/qbit-maid:latest
commands:
- python test_qbitmaid.py
- python test_write_csv.py
when:
branch:
- main
event:
- push
- pull_request
- name: test-dev
image: git.jonb.io/jblu/qbit-maid:dev
commands:
- python test_qbitmaid.py
- python test_write_csv.py
when:
branch:
- dev*
event:
- push
- pull_request
+173
View File
@@ -0,0 +1,173 @@
#!/bin/sh
#
# Trigger a Komodo deploy and optionally wait for it to finish.
#
# Required env:
# KOMODO_URL Base URL of Komodo Core, e.g. https://k.jonb.io
# KOMODO_API_KEY Komodo API key
# KOMODO_API_SECRET Komodo API secret
# KOMODO_TARGET Stack name/id (or Procedure name/id)
#
# Optional env:
# KOMODO_TARGET_TYPE "stack" (default) or "procedure"
# KOMODO_REQUIRED "true" to fail when KOMODO_TARGET is empty
# KOMODO_WAIT "true" (default) to poll until the update completes
# KOMODO_WAIT_TIMEOUT Seconds to wait for completion (default 600)
# KOMODO_WAIT_INTERVAL Seconds between polls (default 10)
#
# Exits non-zero if the request is rejected or the deploy fails.
set -eu
KOMODO_TARGET_TYPE="${KOMODO_TARGET_TYPE:-stack}"
KOMODO_REQUIRED="${KOMODO_REQUIRED:-false}"
KOMODO_WAIT="${KOMODO_WAIT:-true}"
KOMODO_WAIT_TIMEOUT="${KOMODO_WAIT_TIMEOUT:-600}"
KOMODO_WAIT_INTERVAL="${KOMODO_WAIT_INTERVAL:-10}"
if [ -z "${KOMODO_TARGET:-}" ]; then
if [ "$KOMODO_REQUIRED" = "true" ]; then
echo "::error::KOMODO_TARGET is empty but KOMODO_REQUIRED=true" >&2
exit 1
fi
echo "KOMODO_TARGET is not set - skipping Komodo deploy."
exit 0
fi
# Explicit checks rather than ':?', whose exit status differs between
# shells (bash exits 1, dash exits 2). Keep every failure path at exit 1.
if [ -z "${KOMODO_URL:-}" ]; then
echo "::error::KOMODO_URL is required" >&2
exit 1
fi
if [ -z "${KOMODO_API_KEY:-}" ]; then
echo "::error::KOMODO_API_KEY is required" >&2
exit 1
fi
if [ -z "${KOMODO_API_SECRET:-}" ]; then
echo "::error::KOMODO_API_SECRET is required" >&2
exit 1
fi
# Normalise: strip any trailing slash so path joins are predictable.
KOMODO_URL="${KOMODO_URL%/}"
case "$KOMODO_TARGET_TYPE" in
stack)
EXECUTE="DeployStack"
PARAMS="{\"stack\": \"${KOMODO_TARGET}\"}"
;;
procedure)
EXECUTE="RunProcedure"
PARAMS="{\"procedure\": \"${KOMODO_TARGET}\"}"
;;
*)
echo "::error::KOMODO_TARGET_TYPE must be 'stack' or 'procedure', got '${KOMODO_TARGET_TYPE}'" >&2
exit 1
;;
esac
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
# api <path> <json-body> -> writes body to $tmp, echoes HTTP status code
api() {
curl -sS -o "$tmp" -w '%{http_code}' \
-X POST "${KOMODO_URL}$1" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${KOMODO_API_KEY}" \
-H "x-api-secret: ${KOMODO_API_SECRET}" \
-d "$2"
}
echo "Requesting ${EXECUTE} for '${KOMODO_TARGET}' on ${KOMODO_URL}"
if ! code=$(api "/execute/${EXECUTE}" "$PARAMS"); then
echo "::error::Could not reach Komodo at ${KOMODO_URL}" >&2
exit 1
fi
body=$(cat "$tmp")
if [ "$code" != "200" ]; then
echo "::error::Komodo rejected ${EXECUTE} for '${KOMODO_TARGET}' (HTTP ${code})" >&2
echo "$body" >&2
exit 1
fi
echo "Komodo accepted the request (HTTP 200)."
# The execute endpoint returns immediately with an Update; the deploy itself
# runs asynchronously. Poll /read/GetUpdate until it reaches a terminal state.
if [ "$KOMODO_WAIT" != "true" ]; then
echo "KOMODO_WAIT is not 'true' - not waiting for completion."
exit 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not available - cannot poll for completion. Deploy was queued."
exit 0
fi
update_id=$(printf '%s' "$body" | python3 -c '
import json, sys
d = json.load(sys.stdin)
print(d.get("_id", {}).get("$oid", ""))
' 2>/dev/null || true)
if [ -z "$update_id" ]; then
echo "Could not read update id from response - not polling. Deploy was queued."
exit 0
fi
echo "Waiting for update ${update_id} (timeout ${KOMODO_WAIT_TIMEOUT}s)..."
elapsed=0
while [ "$elapsed" -lt "$KOMODO_WAIT_TIMEOUT" ]; do
sleep "$KOMODO_WAIT_INTERVAL"
elapsed=$((elapsed + KOMODO_WAIT_INTERVAL))
if ! code=$(api "/read/GetUpdate" "{\"id\": \"${update_id}\"}"); then
echo "::warning::Could not reach Komodo while polling; retrying." >&2
continue
fi
if [ "$code" != "200" ]; then
echo "::warning::GetUpdate returned HTTP ${code}; retrying." >&2
continue
fi
status=$(python3 -c '
import json, sys
print(json.load(sys.stdin).get("status", ""))
' < "$tmp" 2>/dev/null || true)
case "$status" in
Complete)
if python3 -c '
import json, sys
sys.exit(0 if json.load(sys.stdin).get("success") else 1)
' < "$tmp" 2>/dev/null; then
echo "Deploy of '${KOMODO_TARGET}' completed successfully."
exit 0
fi
echo "::error::Deploy of '${KOMODO_TARGET}' failed. Komodo update log:" >&2
python3 -c '
import json, sys
d = json.load(sys.stdin)
for log in d.get("logs", []):
print(f" [{log.get(\"stage\", \"\")}] {log.get(\"msg\", \"\")}")
' < "$tmp" >&2 2>/dev/null || cat "$tmp" >&2
exit 1
;;
Queued|InProgress)
echo " ${elapsed}s: ${status}"
;;
*)
echo " ${elapsed}s: unexpected status '${status}'"
;;
esac
done
echo "::error::Timed out after ${KOMODO_WAIT_TIMEOUT}s waiting for update ${update_id}" >&2
exit 1
+43
View File
@@ -0,0 +1,43 @@
name: ci
on:
push:
branches:
- dev*
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: git.jonb.io
username: '${{ gitea.actor }}'
password: '${{ secrets.JONBIO_CI }}'
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: git.jonb.io/jblu/qbit-maid:dev
-
name: Checkout
uses: actions/checkout@v4
-
name: Deploy to Komodo
env:
KOMODO_URL: '${{ secrets.KOMODO_URL }}'
KOMODO_API_KEY: '${{ secrets.KOMODO_API_KEY }}'
KOMODO_API_SECRET: '${{ secrets.KOMODO_API_SECRET }}'
# Set the KOMODO_STACK_DEV repository variable to your dev stack name.
# When it is empty the deploy step is skipped, so dev builds still pass.
KOMODO_TARGET: '${{ vars.KOMODO_STACK_DEV }}'
run: sh ./.gitea/scripts/komodo-deploy.sh
+42
View File
@@ -0,0 +1,42 @@
name: ci
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: git.jonb.io
username: '${{ gitea.actor }}'
password: '${{ secrets.JONBIO_CI }}'
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: git.jonb.io/jblu/qbit-maid:latest
-
name: Checkout
uses: actions/checkout@v4
-
name: Deploy to Komodo
env:
KOMODO_URL: '${{ secrets.KOMODO_URL }}'
KOMODO_API_KEY: '${{ secrets.KOMODO_API_KEY }}'
KOMODO_API_SECRET: '${{ secrets.KOMODO_API_SECRET }}'
KOMODO_TARGET: 'qbit-maid'
KOMODO_REQUIRED: 'true'
run: sh ./.gitea/scripts/komodo-deploy.sh
+17
View File
@@ -0,0 +1,17 @@
name: test
on:
push:
branches:
- main
- dev*
pull_request:
jobs:
komodo-deploy-script:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run komodo-deploy.sh tests
run: python3 -m unittest -v test_komodo_deploy
+3 -5
View File
@@ -1,8 +1,6 @@
FROM python:alpine3.18 FROM python:alpine3.23
WORKDIR / WORKDIR /
COPY . opt COPY *.py entrypoint.sh /opt/
RUN apk add --no-cache supercronic RUN pip install requests qbittorrent-api
RUN pip install requests
RUN pip install qbittorrent-api
RUN chmod +x /opt/entrypoint.sh RUN chmod +x /opt/entrypoint.sh
CMD ["/opt/entrypoint.sh"] CMD ["/opt/entrypoint.sh"]
+27 -7
View File
@@ -1,8 +1,7 @@
# qbit-maid # qbit-maid
> Warning: This application removes torrents that are over the minimum age and that are not part of the ignored categories, domains or tags. Please use the delete_torrents feature set to false when first testing its functionality. > Warning: This application removes torrents that are over the minimum age and that are not part of the ignored categories, domains or tags. Please use the delete_torrents feature set to false when first testing its functionality.
![](https://drone.jbranan.com/api/badges/jblu/phc/status.svg) ![Build Status](https://git.jonb.io/jblu/qbit-maid/actions/workflows/docker.yml/badge.svg)
The objective is to remove torrents based on the following criteria: The objective is to remove torrents based on the following criteria:
- tracker domain name - tracker domain name
@@ -13,15 +12,15 @@ The objective is to remove torrents based on the following criteria:
## Install ## Install
### Docker(Recommended) ### Docker(Recommended)
[package ](https://git.jbranan.com/jblu/-/packages/container/qbit-maid/latest) [package](https://git.jonb.io/jblu/-/packages/container/qbit-maid/latest)
docker pull git.jbranan.com/jblu/qbit-maid:latest docker pull git.jonb.io/jblu/qbit-maid:latest
#### Docker Run Command: #### Docker Run Command:
> Please note it is best practice to escape spaces in variables. That is why there is backslashes in the cron schedule. > Please note it is best practice to escape spaces in variables. That is why there is backslashes in the cron schedule.
docker run --name qbit-maid -v /opt/qbit-maid:/config/ -e CRON=0\ 1\ *\ *\ * -e toml_path=/config/config.toml git.jbranan.com/jblu/qbit-maid docker run --name qbit-maid -v /opt/qbit-maid:/config/ -e CRON=0\ 1\ *\ *\ * -e toml_path=/config/config.toml git.jonb.io/jblu/qbit-maid
#### Docker Compose #### Docker Compose
@@ -29,7 +28,7 @@ The objective is to remove torrents based on the following criteria:
version: '3.3' version: '3.3'
services: services:
qbit-maid: qbit-maid:
image: git.jbranan.com/jblu/qbit-maid image: git.jonb.io/jblu/qbit-maid
container_name: qbit-maid container_name: qbit-maid
volumes: volumes:
- /opt/qbit-maid:/config - /opt/qbit-maid:/config
@@ -38,7 +37,7 @@ services:
- toml_path=/config/config.toml - toml_path=/config/config.toml
``` ```
### Via Git ### Via Git
git clone https://git.jbranan.com/jblu/qbit-maid.git git clone https://git.jonb.io/jblu/qbit-maid.git
Qbit-maid will look for an environment variable *toml_path* for its configuration.If it doesn't find it, it will look for a config.toml file in it's own directory. Qbit-maid will look for an environment variable *toml_path* for its configuration.If it doesn't find it, it will look for a config.toml file in it's own directory.
##### config.toml ##### config.toml
@@ -53,6 +52,9 @@ password = "pass"
use_log = true use_log = true
log_level = "INFO" log_level = "INFO"
log_path = "./qc.log" log_path = "./qc.log"
# Optional: log rotation (defaults shown)
log_max_bytes = 5000000
log_backup_count = 3
[app_tags] [app_tags]
protected_tag = "ipt" protected_tag = "ipt"
@@ -99,3 +101,21 @@ save = "save"
use_healthcheck = false use_healthcheck = false
healthcheck_url = "https://example.com/ping/<uuid>>" healthcheck_url = "https://example.com/ping/<uuid>>"
``` ```
## Logging
qbit-maid uses a named logger (`qbit-maid`) that writes to both the configured
log file and the console (useful for Docker/cron output).
- `use_log` — enable or disable logging entirely.
- `log_level` — one of `DEBUG`, `INFO`, `WARN`, `ERROR`, `CRITICAL`.
- `log_path` — path to the log file.
- `log_max_bytes` — rotate the log file once it reaches this size (default 5 MB).
- `log_backup_count` — number of rotated backups to keep (default 3).
Log lines include the timestamp, level, logger name, function, and line number,
for example:
```
10/26/2026 10:10:00 PM [INFO] qbit-maid:tor_processor:24 - Submitted ["My Torrent..."] for deletion.
```
+3
View File
@@ -8,6 +8,9 @@ password = "pass"
use_log = true use_log = true
log_level = "INFO" log_level = "INFO"
log_path = "./qc.log" log_path = "./qc.log"
# Optional: log rotation (defaults shown)
log_max_bytes = 5000000
log_backup_count = 3
[app_tags] [app_tags]
protected_tag = "ipt" protected_tag = "ipt"
+143
View File
@@ -0,0 +1,143 @@
# Auto-deploy qbit-maid to Komodo
Status: **implemented** on branch `dev-komodo-deploy`.
Komodo instance: `https://k.jonb.io` · Stack: `qbit-maid`
## What this does
Every successful image build now triggers a Komodo stack deploy:
| Workflow | Trigger | Image tag | Komodo target |
|---|---|---|---|
| `.gitea/workflows/docker.yml` | push to `main` | `:latest` | stack `qbit-maid` |
| `.gitea/workflows/docker-dev.yml` | push to `dev*` | `:dev` | `vars.KOMODO_STACK_DEV` (skipped if unset) |
The deploy step runs **after** `Build and push`, so the new image is already in
the registry when Komodo pulls it. If the deploy fails, the workflow goes red.
## Why not just use the existing webhook?
You already have webhooks configured, and this is the important part: **a push
webhook on the stack does not reliably deploy new container builds.**
The webhook fires on `git push`. That happens *before* Gitea Actions has built
and pushed the image — the build takes minutes. So Komodo receives the deploy
request while the registry still holds the *previous* `:latest`. It pulls that,
deploys it, and reports success. The new code never lands until something else
triggers a second deploy.
The ordering is the whole problem, and a webhook cannot fix it because the
webhook is tied to the push event, not the build event.
So the workflow calls the Komodo API directly instead:
```
POST https://k.jonb.io/execute/DeployStack
x-api-key: <key>
x-api-secret: <secret>
{"stack": "qbit-maid"}
```
This is the same call the Komodo UI makes when you click **Deploy**, just
triggered from CI at the right moment.
### What to do with the existing webhook
If you have a stack webhook pointing at
`/listener/github/stack/qbit-maid/deploy`, it is now redundant and will cause a
second, useless deploy on every push (deploying the stale image). **Remove it**
in Gitea → repo Settings → Webhooks.
Keep webhooks only if you later point them at a *Procedure* rather than the
stack — see "Using a Procedure" below.
## One Komodo setting to verify
`DeployStack` runs `docker compose pull` before `up` **only when the stack has
`auto_pull` enabled**. It defaults to `true`, so this is probably already fine —
but if it has been turned off, deploys will silently reuse the cached image and
you will see no change.
Check in the Komodo UI: stack `qbit-maid` → Config → **Auto Pull** should be on.
## Setup (one-time)
### 1. Komodo API key
Komodo → **Settings → API Keys → New**. The key's user needs **Execute**
permission on the `qbit-maid` stack. Copy the key and secret — the secret is
shown once.
### 2. Gitea secrets
Repo → **Settings → Actions → Secrets**:
| Name | Value |
|---|---|
| `KOMODO_URL` | `https://k.jonb.io` |
| `KOMODO_API_KEY` | from step 1 |
| `KOMODO_API_SECRET` | from step 1 |
### 3. Dev stack (optional)
Repo → **Settings → Actions → Variables**:
| Name | Value |
|---|---|
| `KOMODO_STACK_DEV` | your dev stack name, e.g. `qbit-maid-dev` |
If this variable is unset, the dev workflow logs `KOMODO_TARGET is not set -
skipping Komodo deploy` and still passes. Dev builds never fail because of a
missing dev stack.
## The deploy script
`.gitea/scripts/komodo-deploy.sh` is shared by both workflows. It:
1. Skips cleanly if `KOMODO_TARGET` is empty (unless `KOMODO_REQUIRED=true`).
2. POSTs to `/execute/DeployStack` (or `/execute/RunProcedure`).
3. Fails on any non-200, printing Komodo's error body.
4. Polls `/read/GetUpdate` until the update reaches `Complete`, then checks
`success` and prints the Komodo log on failure.
The execute endpoint returns immediately with an `Update` object — a `200` means
"queued", not "deployed". That is why step 4 exists.
Tunable via env: `KOMODO_TARGET_TYPE` (`stack`/`procedure`), `KOMODO_WAIT`
(default `true`), `KOMODO_WAIT_TIMEOUT` (default 600s), `KOMODO_WAIT_INTERVAL`
(default 10s).
### Using a Procedure instead of a direct stack deploy
If you later wrap the deploy in a Komodo Procedure (deploy + healthcheck +
notification, say), point the workflow at it instead of editing the script:
```yaml
env:
KOMODO_TARGET_TYPE: 'procedure'
KOMODO_TARGET: 'qbit-maid deploy'
```
`RunProcedure` takes `{"procedure": "<name>"}` and returns the same `Update`
shape, so the polling logic is unchanged.
## Testing
Verified locally against a mock of the Komodo API — 12 checks covering the
success path, deploy failure, bad credentials (401), unknown stack (400),
empty target skip, required-but-empty target, unreachable host, and the
procedure target type. All pass.
Live verification still needed:
1. Push `dev-komodo-deploy` — it matches `dev*`, so `docker-dev.yml` runs. With
`KOMODO_STACK_DEV` unset it will skip the deploy; set the variable first if
you want to exercise it.
2. Merge to `main` and confirm the Komodo UI shows a new `DeployStack` update
for `qbit-maid`, operator = the API key's user.
3. Confirm the running container picked up the new image digest.
## Rollback
Delete the `Deploy to Komodo` step from the workflow(s). Nothing on the Komodo
side is modified by CI beyond triggering a deploy.
+4 -3
View File
@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
CRON_CONFIG_FILE="/opt/crontab" CRON_CONFIG_FILE="/etc/crontabs/root"
echo "${CRON} python /opt/qbit-maid.py" > $CRON_CONFIG_FILE grep -qF 'python /opt/qbit-maid.py' $CRON_CONFIG_FILE || echo "${CRON} python /opt/qbit-maid.py" >> $CRON_CONFIG_FILE
grep -qF '@reboot' $CRON_CONFIG_FILE || echo "@reboot python /opt/qbit-maid.py" >> $CRON_CONFIG_FILE
exec supercronic -passthrough-logs -quiet $CRON_CONFIG_FILE exec crond -f
+17 -12
View File
@@ -38,7 +38,6 @@ class Qbt:
password=self.config["qbittorrent"]["password"], password=self.config["qbittorrent"]["password"],
) )
# Create the logging and pushover objects # Create the logging and pushover objects
self.tl = logging
self.po = pushover self.po = pushover
self.ct = Counter self.ct = Counter
self.cv = csv self.cv = csv
@@ -48,6 +47,12 @@ class Qbt:
self.use_log = self.config["logging"]["use_log"] self.use_log = self.config["logging"]["use_log"]
self.log_path = self.config["logging"]["log_path"] self.log_path = self.config["logging"]["log_path"]
self.log_level = self.config["logging"]["log_level"] self.log_level = self.config["logging"]["log_level"]
self.log_max_bytes = self.config["logging"].get("log_max_bytes", 5_000_000)
self.log_backup_count = self.config["logging"].get("log_backup_count", 3)
self.tl = setup_logging(
self.use_log, self.log_path, self.log_level,
self.log_max_bytes, self.log_backup_count,
)
#app_tags #app_tags
self.tracker_protected_tag = self.config["app_tags"]["protected_tag"] self.tracker_protected_tag = self.config["app_tags"]["protected_tag"]
@@ -91,17 +96,20 @@ class Qbt:
self.healthcheck_url = self.config["healthcheck"]["healthcheck_url"] self.healthcheck_url = self.config["healthcheck"]["healthcheck_url"]
# Calling log and notify functions # Calling log and notify functions
tor_log(self)
tor_notify(self) tor_notify(self)
self.t = time self.t = time
#start healthcheck job
if self.use_healthcheck:
send_ping(self, r, self.healthcheck_url.rstrip("/") + "/start" )
# Pulling domain names to treat carefully # Pulling domain names to treat carefully
self.tracker_list = [] self.tracker_list = []
self.up_tor_counter = 0 self.up_tor_counter = 0
self.preme_tor_counter = 0 self.preme_tor_counter = 0
self.ignored_counter = 0 self.ignored_counter = 0
self.torrent_hash_delete_list = [] self.torrent_hash_delete_list = []
if self.use_log: self.tl.debug(self.tracker_whitelist)
self.tl.debug(self.tracker_whitelist)
#logging in #logging in
try: try:
self.tl.info('Connecting to host.') self.tl.info('Connecting to host.')
@@ -113,17 +121,14 @@ class Qbt:
# Pulling all torrent data # Pulling all torrent data
self.torrent_list = self.qbt_client.torrents_info() self.torrent_list = self.qbt_client.torrents_info()
#Main process block #Main process block
if self.use_log: list_qbit_api_info(self)
list_qbit_api_info(self) list_first_tor(self)
list_first_tor(self) debug_torrent_list(self)
debug_torrent_list(self)
build_tor_list(self) build_tor_list(self)
process_counts(self) process_counts(self)
if self.use_log: torrent_count(self)
torrent_count(self)
tor_processor(self) tor_processor(self)
if self.use_log: print_processor(self)
print_processor(self)
if self.delete_torrents: if self.delete_torrents:
tor_delete(self) tor_delete(self)
self.et = datetime.datetime.now() self.et = datetime.datetime.now()
+7 -14
View File
@@ -7,27 +7,22 @@ def build_tor_list(self):
self.total_torrents = len(self.torrent_list) self.total_torrents = len(self.torrent_list)
while self.torrent_list: while self.torrent_list:
torrent = self.torrent_list.pop() torrent = self.torrent_list.pop()
if self.use_log: self.tl.debug(f'---Analyzing ["{torrent["name"][0:20]}..."] {torrent["infohash_v1"]}---')
self.tl.debug(f'---Analyzing ["{torrent["name"][0:20]}..."] {torrent["infohash_v1"]}---')
# Need a way to tag when the tracker is blank # Need a way to tag when the tracker is blank
if is_tracker_blank(torrent['tracker']): if is_tracker_blank(torrent['tracker']):
if self.use_log: self.tl.warning(f'Torrent doesn\'t have a tracker ["{torrent["name"][0:20]}..."] hash: {torrent["hash"]}')
self.tl.warning(f'Torrent doesn\'t have a tracker ["{torrent["name"][0:20]}..."] hash: {torrent["hash"]}')
self.ignored_counter += 1 self.ignored_counter += 1
continue continue
elif is_cat_ignored(torrent['category'], self.cat_whitelist.values()): elif is_cat_ignored(torrent['category'], self.cat_whitelist.values()):
if self.use_log: self.tl.info(f'Ignored category: ["{torrent["name"][0:20]}..."] category:[{torrent["category"]}] hash: {torrent["hash"]}')
self.tl.info(f'Ignored category: ["{torrent["name"][0:20]}..."] category:[{torrent["category"]}] hash: {torrent["hash"]}')
self.ignored_counter += 1 self.ignored_counter += 1
continue continue
elif is_ignored_tag(self.ignored_tags.values(),torrent['tags']): elif is_ignored_tag(self.ignored_tags.values(),torrent['tags']):
if self.use_log: self.tl.info(f'Ignored tag: ["{torrent["name"][0:20]}..."] tags: {torrent["tags"]} hash: {torrent["hash"]}')
self.tl.info(f'Ignored tag: ["{torrent["name"][0:20]}..."] tags: {torrent["tags"]} hash: {torrent["hash"]}')
self.ignored_counter += 1 self.ignored_counter += 1
continue continue
if is_tag_blank(torrent['tags']): if is_tag_blank(torrent['tags']):
if self.use_log: self.tl.debug(f'Tagging new torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tl.debug(f'Tagging new torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
if is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): if is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
@@ -39,14 +34,12 @@ def build_tor_list(self):
elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
if is_tag_blank(torrent['tags']): if is_tag_blank(torrent['tags']):
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
if self.use_log: self.tl.debug(f'Tagging Protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tl.debug(f'Tagging Protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tracker_list.append(torrent) self.tracker_list.append(torrent)
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
if is_tag_blank(torrent['tags']): if is_tag_blank(torrent['tags']):
self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash'])
if self.use_log: self.tl.debug(f'Tagging Non-protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tl.debug(f'Tagging Non-protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tracker_list.append(torrent) self.tracker_list.append(torrent)
+88 -14
View File
@@ -1,14 +1,6 @@
def tor_log(self): import logging
"""Setting up the log file, if self.use_log is set to true and self.loglevel is DEBUG OR INFO""" from logging.handlers import RotatingFileHandler
if self.use_log:
if self.log_level.lower() == 'debug':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.DEBUG)
elif self.log_level.lower() == 'info':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.INFO)
elif self.log_level.lower() == 'warn':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.WARN)
elif self.log_level.lower() == 'error':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.ERROR)
def tor_notify(self): def tor_notify(self):
"""Seting up to use pushover, if self.use_pushover is set to true and """Seting up to use pushover, if self.use_pushover is set to true and
@@ -79,8 +71,7 @@ def torrent_count(self):
def get_script_runtime(self): def get_script_runtime(self):
elapsed_time = self.et - self.st elapsed_time = self.et - self.st
if self.use_log: self.tl.info(f'Execution time: [{elapsed_time}]')
self.tl.info(f'Execution time: [{elapsed_time}]')
if self.use_pushover: if self.use_pushover:
self.extm = f"Execution time: [{elapsed_time}]" self.extm = f"Execution time: [{elapsed_time}]"
if self.use_apprise: if self.use_apprise:
@@ -88,9 +79,92 @@ def get_script_runtime(self):
def send_ping(self, req_obj, healthcheck_url): def send_ping(self, req_obj, healthcheck_url):
try: try:
req_obj.get(healthcheck_url, timeout=10) req_obj.get(healthcheck_url, timeout=5)
except req_obj.RequestException as e: except req_obj.RequestException as e:
self.tl.info(f"Ping failed: {e}") self.tl.info(f"Ping failed: {e}")
def debug_torrent_list(self): def debug_torrent_list(self):
self.tl.debug(self.torrent_list) self.tl.debug(self.torrent_list)
# ---------------------------------------------------------------------------
# New logging infrastructure (Phase 1 & 3)
# ---------------------------------------------------------------------------
LOGGER_NAME = "qbit-maid"
DEFAULT_MAX_BYTES = 5_000_000
DEFAULT_BACKUP_COUNT = 3
def setup_logging(use_log: bool, log_path: str, log_level: str,
max_bytes: int = DEFAULT_MAX_BYTES,
backup_count: int = DEFAULT_BACKUP_COUNT) -> logging.Logger:
"""Configure and return the qbit-maid logger.
Args:
use_log: If False, adds a NullHandler and sets level to CRITICAL.
log_path: Path to the log file.
log_level: One of debug, info, warn, warning, error, critical.
max_bytes: Max size of a log file before rotation (default 5 MB).
backup_count: Number of rotated backups to keep (default 3).
Returns:
A configured :class:`logging.Logger` named ``"qbit-maid"``.
"""
logger = logging.getLogger(LOGGER_NAME)
# Make setup idempotent: clear any handlers from a previous call
for handler in list(logger.handlers):
logger.removeHandler(handler)
if not use_log:
logger.addHandler(logging.NullHandler())
logger.setLevel(logging.CRITICAL)
return logger
level_map = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warn": logging.WARNING,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
level = level_map.get(log_level.lower(), logging.INFO)
logger.setLevel(level)
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s",
datefmt="%m/%d/%Y %I:%M:%S %p",
)
# File handler with rotation
file_handler = RotatingFileHandler(
log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Console handler for Docker / cron visibility
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
def log_torrent(logger: logging.Logger, level: int, torrent: dict, message: str = ""):
"""Log a torrent record with structured extra fields.
Args:
logger: The qbit-maid logger instance.
level: Logging level constant (e.g. ``logging.INFO``).
torrent: A dict with torrent fields (hash, name, tracker, tags, etc.).
message: Optional human-readable message.
"""
extra = {
"torrent_hash": torrent.get("infohash_v1", ""),
"torrent_name": (torrent.get("name", "") or "")[:20],
"torrent_tracker": torrent.get("tracker", "") or "",
"torrent_tags": torrent.get("tags", "") or "",
}
logger.log(level, message, extra=extra)
+11 -20
View File
@@ -7,31 +7,23 @@ def tor_processor(self):
header = ['state','ratio','tags','added','hash','name','tracker'] header = ['state','ratio','tags','added','hash','name','tracker']
row = [canidate['state'],canidate['ratio'],canidate["tags"],canidate['added_on'],canidate['infohash_v1'],canidate["name"][0:20],canidate['tracker']] row = [canidate['state'],canidate['ratio'],canidate["tags"],canidate['added_on'],canidate['infohash_v1'],canidate["name"][0:20],canidate['tracker']]
write_csv(self.cv,self.telemetry_outfile,header,row) write_csv(self.cv,self.telemetry_outfile,header,row)
if self.use_log: self.tl.debug(f'---Reviewing canidate: ["{canidate["name"][0:20]}..."] {canidate["infohash_v1"]}---')
self.tl.debug(f'---Reviewing canidate: ["{canidate["name"][0:20]}..."] {canidate["infohash_v1"]}---')
if is_downloading(canidate['state']): if is_downloading(canidate['state']):
if self.use_log: self.tl.info(f'["{canidate["name"][0:20]}..."] is still downloading and will be skipped.')
self.tl.info(f'["{canidate["name"][0:20]}..."] is still downloading and will be skipped.')
continue continue
elif is_protected_under_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]): elif is_protected_under_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
if self.use_log: self.tl.debug(f'["{canidate["name"][0:20]}..."] is below a 1.05 ratio({canidate["ratio"]})')
self.tl.debug(f'["{canidate["name"][0:20]}..."] is below a 1.05 ratio({canidate["ratio"]})')
if is_old_tor(canidate['time_active'], self.max_age): if is_old_tor(canidate['time_active'], self.max_age):
if self.use_log: self.tl.debug(f'["{canidate["name"][0:20]}..."] Seconds old: {canidate["time_active"]} Delta: {canidate["time_active"] - self.max_age}')
self.tl.debug(f'["{canidate["name"][0:20]}..."] Seconds old: {canidate["time_active"]} Delta: {canidate["time_active"] - self.max_age}')
self.torrent_hash_delete_list.append(canidate['infohash_v1']) self.torrent_hash_delete_list.append(canidate['infohash_v1'])
if self.use_log:
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.')
elif is_protected_over_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
if self.use_log:
self.tl.debug(f'["{canidate["name"][0:20]}..."] is above a 1.05 ratio({canidate["ratio"]}).')
self.torrent_hash_delete_list.append(canidate['infohash_v1'])
if self.use_log:
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.') self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.')
elif is_protected_over_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
self.tl.debug(f'["{canidate["name"][0:20]}..."] is above a 1.05 ratio({canidate["ratio"]}).')
self.torrent_hash_delete_list.append(canidate['infohash_v1'])
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.')
elif is_not_protected_tor(self.tracker_non_protected_tag, canidate["tags"]): elif is_not_protected_tor(self.tracker_non_protected_tag, canidate["tags"]):
self.torrent_hash_delete_list.append(canidate['infohash_v1']) self.torrent_hash_delete_list.append(canidate['infohash_v1'])
if self.use_log: self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion.')
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion.')
else: else:
if self.enable_dragnet: if self.enable_dragnet:
header = ['state','ratio','tags','added','thash','tname','trname'] header = ['state','ratio','tags','added','thash','tname','trname']
@@ -48,9 +40,8 @@ def tor_delete_tags(self):
def tor_delete(self): def tor_delete(self):
"""Remove torrents, will also delete files, this keeps the filesystem clean. """Remove torrents, will also delete files, this keeps the filesystem clean.
Only pass self.torrent_hash_delete_list if you would like to keep the files.""" Only pass self.torrent_hash_delete_list if you would like to keep the files."""
if self.use_log: self.tl.debug('Hash list submitted for deletion:')
self.tl.debug('Hash list submitted for deletion:') self.tl.debug(self.torrent_hash_delete_list)
self.tl.debug(self.torrent_hash_delete_list)
if self.torrent_hash_delete_list: if self.torrent_hash_delete_list:
self.qbt_client.torrents_delete(True, self.torrent_hash_delete_list) self.qbt_client.torrents_delete(True, self.torrent_hash_delete_list)
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Tests for .gitea/scripts/komodo-deploy.sh.
Runs the real script against a local mock of the Komodo Core API, so no
network access or live Komodo instance is required.
Run with: python3 -m unittest -v test_komodo_deploy
"""
import json
import os
import socket
import subprocess
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(REPO_ROOT, ".gitea", "scripts", "komodo-deploy.sh")
def _free_port():
"""Return a port that is currently closed, for the unreachable-host test."""
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class MockKomodo:
"""Minimal mock of the Komodo Core endpoints used by the deploy script.
``responses`` is a list of ``(status, body)`` pairs consumed in order. A
single-element list repeats forever, which is what the timeout test needs.
Every request is recorded in ``requests`` for assertions.
"""
def __init__(self, responses):
self.responses = list(responses)
self.requests = []
mock = self
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode() if length else ""
mock.requests.append(
{
"path": self.path,
"key": self.headers.get("x-api-key"),
"secret": self.headers.get("x-api-secret"),
"body": raw,
}
)
status, body = (
mock.responses.pop(0)
if len(mock.responses) > 1
else mock.responses[0]
)
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
self.server = HTTPServer(("127.0.0.1", 0), Handler)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.server.shutdown()
self.server.server_close()
@property
def url(self):
return "http://127.0.0.1:%d" % self.port
def run_script(env_overrides, timeout=60):
"""Run the deploy script with a clean KOMODO_* environment."""
env = {k: v for k, v in os.environ.items() if not k.startswith("KOMODO_")}
env.update(env_overrides)
return subprocess.run(
["sh", SCRIPT],
env=env,
capture_output=True,
text=True,
timeout=timeout,
)
def base_env(url, **extra):
env = {
"KOMODO_URL": url,
"KOMODO_API_KEY": "test-key",
"KOMODO_API_SECRET": "test-secret",
"KOMODO_TARGET": "qbit-maid",
"KOMODO_WAIT_INTERVAL": "1",
}
env.update(extra)
return env
class TestKomodoDeployScript(unittest.TestCase):
# --- target selection -------------------------------------------------
def test_skips_when_target_empty(self):
r = run_script({"KOMODO_TARGET": ""})
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("skipping", r.stdout)
def test_fails_when_target_empty_and_required(self):
r = run_script({"KOMODO_TARGET": "", "KOMODO_REQUIRED": "true"})
self.assertEqual(r.returncode, 1)
self.assertIn("KOMODO_REQUIRED=true", r.stderr)
def test_fails_when_url_missing(self):
r = run_script({"KOMODO_TARGET": "qbit-maid"})
self.assertEqual(r.returncode, 1)
self.assertIn("KOMODO_URL is required", r.stderr)
def test_fails_when_api_key_missing(self):
r = run_script(
{
"KOMODO_URL": "http://127.0.0.1:1",
"KOMODO_TARGET": "qbit-maid",
"KOMODO_API_SECRET": "s",
}
)
self.assertEqual(r.returncode, 1)
self.assertIn("KOMODO_API_KEY is required", r.stderr)
def test_invalid_target_type(self):
r = run_script(
{
"KOMODO_URL": "http://127.0.0.1:1",
"KOMODO_API_KEY": "k",
"KOMODO_API_SECRET": "s",
"KOMODO_TARGET": "qbit-maid",
"KOMODO_TARGET_TYPE": "bogus",
}
)
self.assertEqual(r.returncode, 1)
self.assertIn("must be 'stack' or 'procedure'", r.stderr)
# --- request shape ----------------------------------------------------
def test_sends_auth_headers_and_stack_body(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 0, r.stderr)
first = mock.requests[0]
self.assertEqual(first["path"], "/execute/DeployStack")
self.assertEqual(first["key"], "test-key")
self.assertEqual(first["secret"], "test-secret")
self.assertEqual(json.loads(first["body"]), {"stack": "qbit-maid"})
def test_procedure_target_type(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url, KOMODO_TARGET_TYPE="procedure"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(mock.requests[0]["path"], "/execute/RunProcedure")
self.assertEqual(json.loads(mock.requests[0]["body"]), {"procedure": "qbit-maid"})
# --- success and failure ---------------------------------------------
def test_success_path(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("completed successfully", r.stdout)
def test_deploy_failure_surfaces_logs(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(
200,
{
"status": "Complete",
"success": False,
"logs": [{"stage": "Deploy", "msg": "image pull failed"}],
},
),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 1)
self.assertIn("failed", r.stderr)
self.assertIn("image pull failed", r.stderr)
def test_unauthorized(self):
with MockKomodo([(401, {"error": "bad credentials"})]) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 1)
self.assertIn("HTTP 401", r.stderr)
def test_bad_request(self):
with MockKomodo([(400, {"error": "stack not found"})]) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 1)
self.assertIn("HTTP 400", r.stderr)
def test_unreachable_host(self):
r = run_script(base_env("http://127.0.0.1:%d" % _free_port()))
self.assertEqual(r.returncode, 1)
self.assertIn("Could not reach Komodo", r.stderr)
# --- polling ----------------------------------------------------------
def test_no_wait_returns_after_accept(self):
with MockKomodo([(200, {"_id": {"$oid": "u1"}})]) as mock:
r = run_script(base_env(mock.url, KOMODO_WAIT="false"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("not waiting", r.stdout)
self.assertEqual(len(mock.requests), 1)
def test_polls_until_complete(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Queued"}),
(200, {"status": "InProgress"}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Queued", r.stdout)
self.assertIn("InProgress", r.stdout)
self.assertIn("completed successfully", r.stdout)
self.assertEqual(len(mock.requests), 4)
def test_timeout(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "InProgress"}),
]
) as mock:
r = run_script(
base_env(mock.url, KOMODO_WAIT_TIMEOUT="2", KOMODO_WAIT_INTERVAL="1")
)
self.assertEqual(r.returncode, 1)
self.assertIn("Timed out", r.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)
+13
View File
@@ -1,6 +1,8 @@
import unittest import unittest
import requests as r
from qlist import is_preme,is_cat_ignored,is_tracker_blank,is_protected_tracker,is_not_protected_tracker,is_tag_blank,is_ignored_tag from qlist import is_preme,is_cat_ignored,is_tracker_blank,is_protected_tracker,is_not_protected_tracker,is_tag_blank,is_ignored_tag
from qprocess import is_downloading,is_protected_under_ratio,is_old_tor,is_protected_over_ratio,is_not_protected_tor from qprocess import is_downloading,is_protected_under_ratio,is_old_tor,is_protected_over_ratio,is_not_protected_tor
from qlogging import send_ping
class TestQbitmaid(unittest.TestCase): class TestQbitmaid(unittest.TestCase):
def test_ispreme_sanity(self): def test_ispreme_sanity(self):
@@ -94,6 +96,17 @@ class TestQbitmaid(unittest.TestCase):
def test_isignoredtag(self): def test_isignoredtag(self):
self.assertTrue(is_ignored_tag(['save'], 'save,public,ipt')) self.assertTrue(is_ignored_tag(['save'], 'save,public,ipt'))
def test_sendpingstart_sanity(self):
send_ping(self, r, "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb" + "/start")
url = "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb"
send_ping(self, r, url)
def test_sendping_start(self):
url = "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb/"
send_ping(self, r, url.strip("/") + "/start")
send_ping(self, r, "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb")
# def test__sanity(self): # def test__sanity(self):
# pass # pass