Skip to content

Commit 0f9c0d2

Browse files
authored
Merge pull request #23 from ThatStasGuy/add-documentation
docs: generating documentation for the architecture
2 parents bab0f05 + 5315364 commit 0f9c0d2

9 files changed

Lines changed: 1975 additions & 0 deletions

docs/01-overview.md

Lines changed: 212 additions & 0 deletions
Large diffs are not rendered by default.

docs/02-invocation-control-flow.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Invocation & Control Flow (5 entry modes)
2+
3+
`pullab_cloud` (Python package `kernel_ci_cloud_labs`) has five entry points. All converge on the same registry-driven pattern (build `auth` -> `storage` -> `provider`, then `run_pipeline`), but differ in who triggers them, where config comes from, and what wraps the pipeline call.
4+
5+
| # | Entry mode | Source symbol | Trigger |
6+
|---|------------|---------------|---------|
7+
| 1 | CLI subcommand | `kernel_ci_cloud_labs.cli:main` | Operator running `kernel-ci-cloud-runner aws run ...` |
8+
| 2 | Library `main()` | `kernel_ci_cloud_labs.main:main` | `python -m`/import; default `config_path="examples/aws/config.json"` |
9+
| 3 | EventBridge / Lambda (one-shot pipeline) | `kernel_ci_cloud_labs.eventbridge_handler.lambda_handler` | EventBridge scheduled rule / custom event |
10+
| 4 | Pull-lab poller (CLI / long-running loop) | `kernel_ci_cloud_labs.pull_labs_poller:main` | Container loop or cron with `--once` |
11+
| 5 | Pull-lab poller (Lambda) | `kernel_ci_cloud_labs.pull_labs_poller.lambda_handler` | Lambda, single poll cycle per invocation |
12+
13+
Modes 1-3 directly run one pipeline. Modes 4-5 poll `kernelci-api` for pull-lab jobs and invoke the pipeline indirectly through a pluggable *job executor*.
14+
15+
---
16+
17+
## The shared core: registry instantiation
18+
19+
Four of the five modes build the same trio from the registries in `kernel_ci_cloud_labs.core.registry`, keyed off the config dict:
20+
21+
- `AUTH_REGISTRY[config["auth_credentials"]["auth_provider"]]`
22+
- `STORAGE_REGISTRY[config["storage"]["type"]]`
23+
- `PROVIDER_REGISTRY[config["provider"]]`
24+
25+
To populate the registries, every submodule of `providers`, `storage`, and `auth` must be imported first so the `register_*` decorators run. `import_all_packages` (`main.py`) does this via `importlib.import_module` plus `pkgutil.iter_modules` walking `package.__path__`.
26+
27+
The storage object is always built from a *merged* config - `config["storage"]` plus root-level `region` and `external_storage` - and this exact merge is duplicated in all four call sites (`cli.py`, `main.py`, `eventbridge_handler.py`, `pull_labs_poller.py`):
28+
29+
```python
30+
storage_config = {
31+
**config["storage"],
32+
"region": config.get("region"),
33+
"external_storage": config.get("external_storage", {}),
34+
}
35+
```
36+
37+
`run_pipeline` lives in `core/pipeline.py` with signature `run_pipeline(provider, storage, run_dir=None)` and returns the summary dict. The generated run prefix has the format `run_{test_id}_{datetime}`, and per-instance VM console logs land at the S3 key `{run_prefix}/test_{test}/output/{instance_id}/console-output.log` (`launch_vm.py`).
38+
39+
---
40+
41+
## Mode 1 - CLI subcommand (`cli.py`)
42+
43+
The console-script `kernel-ci-cloud-runner` is bound to `kernel_ci_cloud_labs.cli:main` (`setup.py`). `main()` builds a nested argparse tree (`cloud` -> `command` -> `setup_command`) and dispatches via `args.func(args)`. When no `func` is bound (incomplete subcommand), it prints help for the *deepest subparser reached* and exits code `1`: `setup` help when `cloud==aws and command==setup`, otherwise the `aws` parser help, otherwise the top-level parser help.
44+
45+
The pipeline-running subcommand `aws run` is handled by `cmd_run`. Control flow:
46+
47+
1. Set up logging into a fresh run directory.
48+
2. Resolve config path: `config_path = args.config` by default, but **if `args.config_s3` is set, config is downloaded from S3 to a `NamedTemporaryFile(delete=False)` and `config_path` is reassigned** - so `--config-s3` takes precedence over `--config` (used for EventBridge-style triggers passing config in S3).
49+
3. `import_all_packages` runs for `providers`, `storage`, `auth` *before* config load and registry lookups.
50+
4. Load config JSON and call `load_credentials(config_path)`.
51+
5. Build `auth`, the merged `storage_config` + `storage`, then `provider`.
52+
6. Call `run_pipeline(provider, storage, run_dir=run_dir)` directly.
53+
54+
```mermaid
55+
flowchart TD
56+
Main["main()"] --> Parse["argparse parse_args"]
57+
Parse --> HasFunc{"hasattr args func?"}
58+
HasFunc -->|no| Help["print deepest subparser help<br/>sys.exit(1)"]
59+
HasFunc -->|yes| Dispatch["args.func(args)"]
60+
Dispatch --> CmdRun["cmd_run"]
61+
CmdRun --> S3{"args.config_s3 set?"}
62+
S3 -->|yes| Dl["download to NamedTemporaryFile<br/>reassign config_path"]
63+
S3 -->|no| UseCfg["use args.config"]
64+
Dl --> Imports["import_all_packages x3"]
65+
UseCfg --> Imports
66+
Imports --> Build["auth / storage / provider"]
67+
Build --> Run["run_pipeline(provider, storage, run_dir)"]
68+
```
69+
70+
Note: CLI failure paths use `sys.exit(1)` (`cli.py`); subcommands like `validate` and `analyze` propagate the underlying function's return value through `sys.exit(...)`.
71+
72+
---
73+
74+
## Mode 2 - Library `main()` (`main.py`)
75+
76+
`main.py:main(config_path="examples/aws/config.json")` is the canonical library invocation and the template the other modes mirror. Module import has side effects: at import time it reads `LOG_LEVEL`, creates a run directory, and configures logging.
77+
78+
`main()`:
79+
80+
1. `import_all_packages` for `providers`, `storage`, `auth` to populate registries.
81+
2. Load config JSON.
82+
3. `load_credentials(config_path)` - reads a `credentials.json` sibling of the config file; returns `None` (after a warning) if absent.
83+
4. Look up the three registry classes, build `auth`, merged `storage`, and `provider`.
84+
5. `run_pipeline(provider, storage, run_dir=run_dir)`.
85+
86+
---
87+
88+
## Mode 3 - EventBridge / Lambda one-shot (`eventbridge_handler.py`)
89+
90+
The Lambda entry point is an alias: `lambda_handler = handle_eventbridge`. The Lambda handler name is `kernel_ci_cloud_labs.eventbridge_handler.lambda_handler`.
91+
92+
`handle_eventbridge(event, context=None)` control flow:
93+
94+
1. Set up per-invocation logging and generate an `invocation_id`.
95+
2. Read `config_s3_uri` from the event; if missing, **return `{"status": "error", ...}` immediately**. Resolve `region` from `event["region"]`, else `AWS_DEFAULT_REGION`, else `us-west-2`.
96+
3. Inside a `try`:
97+
- Download config from S3 to a temp file (`_download_config`) and load it.
98+
- `_prepare_kernel_rpms(config, region)` - a logging-only no-op expecting RPMs to be pre-uploaded.
99+
- `_make_config_run_local(config)` - appends `uuid4().hex[:8]` to `test_config["test_id"]` so parallel invocations write to distinct S3 prefixes.
100+
- Write the mutated config back to the temp file.
101+
- Import packages, build `auth`/`storage`/`provider`, then call `run_pipeline(provider, storage, run_dir=run_dir)` directly.
102+
- Return `{"status": "success", ...}`.
103+
4. `except Exception` returns `{"status": "error", ...}`.
104+
5. `finally`: if `config_path` is bound, best-effort `os.unlink(config_path)`, swallowing `OSError`.
105+
106+
---
107+
108+
## Modes 4 & 5 - Pull-lab poller (`pull_labs_poller.py`)
109+
110+
The poller bridges `kernelci-api` and `pullab_cloud`: it polls for available pull-lab job nodes, claims each, translates its job definition into a run config, runs it via a *job executor*, and finishes the node back in `kernelci-api`. KCIDB direct submission is currently disabled (see below).
111+
112+
### Construction and configuration precedence (`PullLabsPoller.__init__`)
113+
114+
- `api_token` precedence: `KERNELCI_API_TOKEN` -> `UNIFIED_TOKEN` -> `config["kernelci"]["api_token"]`.
115+
- `poll_interval_sec` default `30` from `DEFAULT_POLL_INTERVAL_SEC`.
116+
- Cursor file default `/tmp/pullab_cloud_cursor.json` from `DEFAULT_CURSOR_FILE`.
117+
- KCIDB endpoint resolved by `_resolve_kcidb_endpoint` with four-tier precedence: (`KCIDB_SUBMIT_URL` + `KCIDB_JWT`) > `KCIDB_REST` (`https://<token>@host/submit`) > `UNIFIED_TOKEN` (+ `KCIDB_SUBMIT_URL` or config URL) > config `kcidb_submit_url`/`kcidb_jwt`.
118+
- `self.job_executor` defaults to `_default_job_executor`; a custom executor passed to the constructor bypasses it.
119+
- With no custom executor, `_validate_default_executor_deps()` eagerly imports `boto3` plus the executor packages so a missing dependency fails at startup, not on first event.
120+
- `_validate_api_token` does a one-shot `GET /whoami` preflight; **non-fatal** - a transient error only logs a warning.
121+
122+
### Default job executor (`_default_job_executor`)
123+
124+
Mirrors the registry pattern with two differences from `main.py`:
125+
126+
1. `auth` is built with **`credentials=None`** - the poller has no `credentials.json` step.
127+
2. `run_pipeline(provider, storage)` is called with **no `run_dir`**, so the pipeline creates its own.
128+
129+
It then calls `_extract_test_results(summary or {})` to produce `(per_test_results, optional_log_url)`.
130+
131+
### Polling and per-event flow
132+
133+
`fetch_events(from_ts)` GETs `/events` with `state=available`, `kind=job`, `recursive=true`, `limit=1000`, `from=<from_ts>`.
134+
135+
`process_event` returns `True` (benign skip) when: runtime mismatch, platform mismatch, no `job_definition` artifact, or the node cannot be claimed.
136+
137+
Once claimed, a node *must* be finished: a default `NodeOutcome("incomplete", "Infrastructure", "unexpected internal error")` is set, `_execute_job` runs inside a `try`, and `_finish_node(node_id, node_outcome)` is always called in the `finally`.
138+
139+
`_claim_node` claims by writing `data.job_id = "<runtime_name>:<uuid4().hex>"` while the node stays `available`. It re-reads the node first and skips if `state != "available"` or if `data.job_id` is already set. The claim is best-effort - `kernelci-api` has no compare-and-set, so the PUT is a full-document overwrite and two pollers can both claim the same node; parallel pollers must be partitioned by platform.
140+
141+
In `_execute_job`, if the job executor raises, it is an infrastructure failure: outcome becomes `incomplete`/`Infrastructure`, and a synthetic `{"name": "boot.infrastructure", "status": "ERROR"}` row is emitted.
142+
143+
**KCIDB direct submission is commented out / disabled.** Instead, the boot log URL is written onto the maestro node's `artifacts.test_log` (extra URLs under `test_log_{i}`), which `send_kcidb` later picks up.
144+
145+
`poll_once` reads the cursor, fetches events, processes each (swallowing per-event exceptions), advances `last_ts` to the last event's `timestamp`, writes the cursor if it changed, and returns the processed count.
146+
147+
`run_forever` loops `poll_once` and only sleeps `poll_interval_sec` when `count == 0`.
148+
149+
```mermaid
150+
flowchart TD
151+
Poll["poll_once"] --> Fetch["fetch_events(from_ts)<br/>state=available kind=job"]
152+
Fetch --> Loop["for each event"]
153+
Loop --> Match{"runtime + platform match<br/>and job_definition present?"}
154+
Match -->|no| Skip["return True (skip)"]
155+
Match -->|yes| Claim{"_claim_node OK?"}
156+
Claim -->|no| Skip
157+
Claim -->|yes| Exec["_execute_job"]
158+
Exec --> Trans["translate_job(jobdef, base_config, node_id)"]
159+
Trans --> JobEx["job_executor(run_config)"]
160+
JobEx -->|raises| Infra["incomplete / Infrastructure<br/>boot.infrastructure ERROR row"]
161+
JobEx -->|ok| Rows["build_test_row per result"]
162+
Rows --> Artifacts["write log URL to artifacts.test_log"]
163+
Infra --> Finish["_finish_node (in finally)"]
164+
Artifacts --> Finish
165+
```
166+
167+
### CLI vs Lambda entry points
168+
169+
**Mode 4 - `main(argv)`** builds an argparse parser with `--config`, `--once`, `--log-level`. It loads the base config (`_load_base_config`) and constructs the poller. With `--once` it runs a single `poll_once` and returns `0`; otherwise it calls `run_forever()`.
170+
171+
**Mode 5 - `lambda_handler(event, context=None)`** runs a single `poll_once` per invocation. It reads `config_path` from `event["config_path"]` or the `PULLAB_BASE_CONFIG` env var, loads config, constructs the poller, and returns `{"status": "ok", "processed": n}`.
172+
173+
`_load_base_config` resolves its path from the argument, else `PULLAB_BASE_CONFIG`, else `examples/aws/config.json`.
174+
175+
### Translation (`translate_job`)
176+
177+
`_execute_job` calls `translate_job(jobdef, self.base_config, node_id=node_id)` - only `node_id` is passed; `platform_map` and `test_type_map` default internally to `DEFAULT_PLATFORM_MAP` / `DEFAULT_TEST_TYPE_MAP` (`pull_labs_translate.py`). `translate_job` (`pull_labs_translate.py`) deep-copies `base_config` and rewrites `test_config`; it raises `ValueError` if `artifacts.kernel` or `artifacts.modules` is missing.
178+
179+
---
180+
181+
## Environment variables referenced across the entry modes
182+
183+
| Env var | Used by | Purpose |
184+
|---------|---------|---------|
185+
| `LOG_LEVEL` | all modes | logging level |
186+
| `KERNELCI_API_BASE_URI` | poller | kernelci-api base URI |
187+
| `KERNELCI_API_TOKEN` / `UNIFIED_TOKEN` | poller | API token (in that precedence) |
188+
| `KERNELCI_RUNTIME_NAME` | poller | runtime label used in claim job_id |
189+
| `KERNELCI_PLATFORMS` | poller | optional platform allowlist |
190+
| `KCIDB_SUBMIT_URL` / `KCIDB_JWT` / `KCIDB_REST` / `KCIDB_ORIGIN` | poller | KCIDB endpoint resolution |
191+
| `PULLAB_BASE_CONFIG` | poller (CLI + Lambda) | default base config path |
192+
| `PULLAB_POLL_INTERVAL_SEC` / `PULLAB_CURSOR_FILE` | poller | poll interval / cursor file overrides |
193+
| `AWS_DEFAULT_REGION` | eventbridge_handler | region fallback (-> `us-west-2`) |
194+
195+
---
196+
197+
## Summary
198+
199+
All five entry modes funnel through the same registry-based `auth -> storage -> provider -> run_pipeline` core. Modes 1-3 run exactly one pipeline per invocation, sourcing config from a local path (mode 2), an optional S3 override (mode 1), or a required S3 URI (mode 3). Modes 4-5 add a polling/claim/translate/finish loop, deferring the pipeline run to a swappable job executor (the default re-uses the same core, minus `credentials.json` and minus an externally supplied `run_dir`). Direct KCIDB submission in the poller is currently disabled in favor of writing the boot-log URL onto the maestro node's `artifacts.test_log`.

0 commit comments

Comments
 (0)