|
| 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