Dependencies
- Installing a Package on a Running Deployment
- Adding a Package at Build Time
- What the Build Actually Does
- Doing It by Hand
- What Will Not Work
- Packages Worth Vendoring
- Costs
- Keeping It Reproducible
Inline snippets can use third-party Python packages. There are two ways to get one, and they differ in when the work happens rather than in what you end up with:
| Question | Installed at run time | Vendored at build time |
|---|---|---|
| You edit | config/packages.json on the search head | requirements.txt in the source tree |
| Takes effect | next daemon start, no rebuild | after make python precompile and a reinstall |
| Needs | network access from the search head to an index | a build machine |
| Best for | adding a package to a deployment you have already shipped | packages every deployment should have |
Pure Python only, either way. A package that ships a compiled extension module is built for a physical CPU, and the sandbox is WebAssembly. Both paths detect this and refuse, rather than producing something that fails at search time. See What will not work.
Installing a Package on a Running Deployment
Write config/packages.json inside the app directory:
{
"packages": [
{ "name": "jmespath", "version": "1.0.1" },
{ "name": "python-dateutil", "version": "2.9.0.post0" }
]
}
The daemon reads it at startup, downloads each wheel, checks its SHA-256
against what the index published, unpacks it into packages/, and records what
it did in packages/installed.json. Restart the daemon and the package is
importable:
pkill -f etc/apps/babysoarus/bin/babysoarus-daemon
There is no need to restart Splunk: the next search starts the daemon again,
and it finishes reconciling before it accepts a connection, so the first search
after the restart already sees the package. Watch it happen in
$SPLUNK_HOME/etc/apps/babysoarus/var/daemon.log:
INFO installed Python package package=jmespath version=1.0.1 files=8
INFO reconciled Python packages installed=1 removed=0 unchanged=0 failed=0
INFO precompiled Python bytecode compiled=8 failed=0 elapsed_ms=41
That last line is the daemon compiling the package's bytecode straight away,
so the first search does not have to. The result is cached in var/pycache/,
is regenerable, and is removed along with the package if you withdraw it.
A package that cannot be installed is logged as an error and the daemon still starts. That is deliberate: one bad entry should cost you that package, not every search on the search head.
The file is declarative: the daemon makes packages/ match it. Removing an
entry uninstalls it, changing a version replaces it, and running with the same
file twice does nothing the second time. Only files the manifest records are
ever deleted, so anything you place in packages/ by hand survives.
No file and an empty file mean different things.
{"packages": []}means "manage this, and it should be empty", so it uninstalls everything. Deletingconfig/packages.jsonaltogether means "do not manage this", and leaves what is already installed alone. If you want the packages gone, empty the list rather than deleting the file.
Versions Are Exact, on Purpose
"version": "1.0.1" is required. Ranges such as >=1.0 are refused, because
two search heads reconciling a week apart would then install different code
from the same configuration. If you want to be stricter still, pin the wheel
itself:
{ "name": "jmespath", "version": "1.0.1", "sha256": "02e2e4cc71b5bcd8..." }
Then the index must offer that exact wheel, or nothing is installed.
Using an Internal Index
Most enterprise search heads cannot reach pypi.org. Point at a mirror:
{
"index_url": "https://pypi.internal.example.com",
"packages": [{ "name": "jmespath", "version": "1.0.1" }]
}
The URL must speak the PyPI JSON API, which is what Artifactory, Nexus and
devpi all provide: the daemon reads <index_url>/pypi/<name>/<version>/json.
If the mirror presents a certificate signed by your own CA, point the daemon at the bundle:
export BABYSOARUS_CA_BUNDLE=/etc/pki/tls/certs/corporate-ca.pem
The daemon trusts the public roots and the system trust store already; this adds to them. It is validated when read, so a path that does not exist, or a file with no certificates in it, is an error at startup rather than a confusing TLS failure later.
Why not
SSL_CERT_FILE? Splunk sets that variable in every process it starts, and points it at its own trust store. On some versions it points at a file that is not shipped at all. BabySOARus therefore ignores it and usesBABYSOARUS_CA_BUNDLEinstead, so that what the daemon trusts is something you chose rather than something you inherited.
The Network Policy Applies to Installs Too
If config/acl.json is present and enforcing, the index
host must be permitted by it. The daemon will not fetch from a host you have
denied to guest code, and says so rather than failing quietly:
the network policy does not permit pypi.org, so packages cannot be installed
from https://pypi.org. Add it to allow_hosts in config/acl.json, or point
index_url at a mirror that is permitted
With no policy file at all, egress is unrestricted and so is the installer.
Turning Installation Off without Deleting the File
{
"enabled": false,
"packages": [{ "name": "jmespath", "version": "1.0.1" }]
}
enabled defaults to true, so omitting it installs. Setting it to false
stops the daemon reconciling from the file while leaving the list intact, which
is what you want when isolating whether a package is behind a problem. It does
not uninstall anything already in packages/; removing an entry does that.
What Guest Code Sees
packages/ is preopened read-only at /packages and appended to
sys.path. Read-only is deliberate: code that could write there could replace
the module the next search imports. Bytecode goes to a separate writable
directory instead, so importing a package does not recompile it every run.
Because /packages is appended rather than prepended, an installed package can
never shadow the standard library or a build-time vendored package. A package
named json will not change what json means in an existing snippet.
Adding a Package at Build Time
Add it to python/exec_component/requirements.txt:
python-dateutil==2.9.0.post0
jmespath==1.0.1
Then rebuild:
make python precompile
SPLUNK_HOME=/opt/splunk make install
That is it. The package is now importable:
| makeresults
| eval doc="{\"a\": {\"b\": [10, 20, 30]}}"
| exec inline="import jmespath
for e in events:
e['picked'] = jmespath.search('a.b[1]', json.loads(e['doc']))"
_time doc picked
--------------------------- -------------------------- ------
2026-07-31 21:34:51.000 BST {"a": {"b": [10, 20, 30]}} 20
Vendored packages are also in scope without importing them, so
jmespath.search(...) works directly.
What the Build Actually Does
make python runs python/vendor-deps.py, which:
- installs your requirements with
pip install --target python/exec_component/vendor, falling back touv pip install --targetif pip is not on the path; - scans the result for compiled extension modules and stops if it finds any;
- writes
python/exec_component/_vendored.py, a generated module containing oneimportline per vendored package; - passes
-p . -p vendortocomponentize-pyso both directories are on the interpreter's path.
Step 3 is the one that matters. componentize-py resolves imports when the
component is built and snapshots the interpreter's memory, so a package
that merely exists on disk is not importable at search time. The generated
module forces every vendored package to be imported during the build, which
puts it in the snapshot.
# python/exec_component/_vendored.py, generated
import jmespath
VENDORED = {
"jmespath": jmespath,
}
You never edit that file. It is regenerated from requirements.txt.
Doing It by Hand
If you would rather drive the install yourself:
pip install --target python/exec_component/vendor jmespath==1.0.1
python3 python/vendor-deps.py # regenerates _vendored.py from what is there
make python precompile
vendor-deps.py reinstalls from requirements.txt when run on its own, so
keep the file in step with what you want vendored.
What Will Not Work
Anything with a .so, .pyd or .dylib in it. The build says so plainly:
these vendored files are compiled for a native CPU and cannot run inside
WebAssembly:
orjson/orjson.cpython-312-x86_64-linux-gnu.so
Only pure-Python packages can be vendored. Look for a pure-Python alternative,
or move the work to the host side of a custom .wasm function. Pass
--allow-native to install anyway.
The usual suspects, and what to do instead:
| Package | Why not | Instead |
|---|---|---|
numpy, pandas, scipy | Compiled | statistics, or a Rust custom function |
requests, httpx | Need real sockets | http_request, which the host performs |
cryptography, pyOpenSSL | Compiled | hashlib, hmac, secrets |
orjson, ujson | Compiled | json, already present |
lxml | Compiled | xml.etree.ElementTree, already present |
psycopg, pymysql | Need sockets | Query via an HTTP API, or a custom function |
regex | Compiled | re, already present |
A useful test before you try: if the wheel on PyPI is named
something-1.2.3-py3-none-any.whl, it is pure Python and will work. If it is
named something-1.2.3-cp314-cp314-manylinux_x86_64.whl, it will not: a
cp tag means compiled code, whichever version follows it.
Packages Worth Vendoring
These are pure Python, small, and genuinely useful in a SOC context:
| Package | For |
|---|---|
python-dateutil | Parsing the timestamp formats datetime refuses |
jmespath | Querying deeply nested JSON without ten .get() calls |
tldextract | Correct public-suffix handling for domains |
pyparsing | Grammar-based parsing of odd log formats |
cbor2 | CBOR payloads |
chardet | Character-set detection on messy fields |
stix2-patterns | Validating STIX patterns from a feed |
Costs
Every vendored package increases the component image, its build time, and the memory each pooled instance maps. The interpreter image is about 33 MB before any dependencies. A handful of small pure-Python packages adds a megabyte or two and is not worth worrying about; vendoring something enormous is.
Because the image is mapped copy-on-write into each pooled instance, size costs address space rather than per-search CPU. It does not slow searches down. It does make the build take longer.
Keeping It Reproducible
Pin exact versions in requirements.txt. The vendor directory is generated
and should not be committed, but requirements.txt and the generated
_vendored.py describe exactly what a build will contain, so both belong in
version control if you want reproducible interpreters across a fleet.