Package indexes
A package index is a server that Python packages are installed from, such as a company's
own package server or a code host's. An administrator registers an index once, for the whole
instance, with a name, a URL and, if the index asks for a user name and password, the name of a
secret that holds them. The secret is created in each environment whose projects use the index,
so the credential can differ between dev and prod. A project's uv.lock may name the URLs of
registered indexes, and no others (see "Sources a lock may name").
The record
| Field | Type | Meaning |
|---|---|---|
id |
UUID | The index's identifier. |
name |
text | The name you use in commands. Unique. Never changes. |
url |
text | The index's address, in the stored form described below. Unique. |
credential_secret |
text or null |
The name of the secret that holds username and password, or null for an index that asks for none. Only the name is stored here. |
created_by |
UUID or null |
The principal that registered it. |
created_at |
time | When it was registered. |
updated_at |
time | When it was last changed. |
- The name follows the rules of a repository name: no
/, no., at most 255 characters. - The URL is at most 1024 characters of printable ASCII, with no backslash, and starts with
https://.http://is accepted only forlocalhostand loopback addresses. It has no user name, no password, no?and no#: put the credential in the secret. - The host is a plain DNS name (letters, digits and hyphens between dots, in lower case once
stored; an international name in its
xn--form) or an IP address written in its canonical form (an IPv4 address as four decimal numbers, an IPv6 address in brackets in its shortest form). A host name with an underscore is not supported. An international host name is written in itsxn--form; Lakana does not check that form's validity, so an invalid one is refused later, when uv builds. A port is between 1 and 65535. - The path is made of segments of letters, digits and
- . _ ~ + @, with no empty segment and no.or..segment. A path with a percent sign or a space is not supported: use a URL whose path has none. - The host may not be one of six hosts of the public index (
pypi.org,www.pypi.org,upload.pypi.org,pypi.python.org,files.pythonhosted.org,test-files.pythonhosted.org), with or without a credential: PyPI never receives one, and a lock may namehttps://pypi.org/simplewithout registration. - TestPyPI (
test.pypi.org) may be registered, but only without acredential_secret: its downloads need none. Registering it with one, or adding one later, is refused with the messageThe package index URL is TestPyPI's address. TestPyPI needs no credential for downloads; register it without one (leave out --credential-secret).So none of the seven hosts ever receives a credential. - The URL and the credential are checked together whenever either changes, and again by the
worker before it reads a secret. A stored URL that fails the check then (possible only for a
row changed in the database directly) stops the deploy or the build with a message that
contains
the stored URL of the package index, and no credential is sent. - The URL is stored with scheme and host in lower case, without the default port (
:443) and without trailing slashes (one or several), sohttps://Pkgs.Example.org:443/simple/is stored ashttps://pkgs.example.org/simple. A name and a stored URL are each unique: registering a second spelling of a URL that is already registered is refused, and the message names the index that has it. credential_secretis the name of a secret: 1 to 255 characters, with no space, control character or:, or empty.
Two forms that many indexes use work as they are:
- GitLab: use the numeric project ID,
https://gitlab.example.org/api/v4/projects/1234/packages/pypi/simple, not the project's path (its%2Fis a percent sign). - devpi:
https://devpi.example.org/team/prod/+simple(the+is allowed).
A refused URL gets a message that says which rule it broke and what to write instead.
The secret
An index with a credential_secret needs a secret of that name in each environment whose
projects use the index. The secret holds two keys:
| Key | Meaning |
|---|---|
username |
The user name. Optional: when it is absent, an empty user name is sent. |
password |
The password or token. Required. |
Every other character is allowed in both, except a NUL character and a character that cannot be
written as UTF-8 (a lone surrogate): neither can be passed on. A secret with a NUL character counts
as having no usable password; one with a lone surrogate is refused with its own message. The user name and the password are at most 4,096 characters each; a longer one is refused, at a deploy and before a run, with its own message. A user name of at least 8 characters is redacted from uv's output with the password, except the fixed names many services prescribe (__token__, gitlab-ci-token, x-access-token, …), which are not secret. The secret is created with lakana secret create NAME
--env ENV --values-stdin and set again with lakana secret rotate NAME --env ENV
--values-stdin; both read a JSON object from standard input.
A deploy whose lock uses a registered index with a credential_secret stops unless that secret
exists in the target environment and holds a usable password; the message says which command
creates or sets it again. A deploy records the names of the registered indexes its lock uses, and
lakana project-version show prints them under a line package indexes:, one per line as - alpha
and - team, with control characters written as escapes (the first 20, then a line
and N more: use --output json); a project version that uses none prints no
such line.
Sources a lock may name
At deploy, and again before every execution, Lakana reads the project's uv.lock and refuses it
when a package comes from a source it does not know. Two URLs name the same index when they
differ only in the letter case of scheme and host, in a default port that is written out and in
trailing slashes.
| Source in the lock | Verdict |
|---|---|
The registry https://pypi.org/simple |
allowed |
The folder wheelhouse at the root of the project |
allowed |
The index the operator set in LAKANA_RUN__INDEX_URL |
allowed, when it is set |
| A registered index's URL | allowed |
| Any other index URL | refused; the message names the package and the URL and says to ask an administrator to register the index. A URL that cannot be registered as written (http for a remote host, % in the path, a user name, ...) gets no command: the message says why, and to take the package from a registered index |
A file of the folder wheelhouse whose path leaves it (.., a leading /, a backslash) |
refused; packages in wheelhouse must be files inside that folder |
| Another local folder | refused |
git, a direct url |
refused; the message says to publish the package to a package index and register that index |
path, directory, editable, virtual (other than the project itself) |
refused |
Three rules apply to pyproject.toml:
- A
[[tool.uv.index]]table whose URL is a registered index's (a user name in the URL does not change that) must haveexplicit = true, and the packages that come from it are named under[tool.uv.sources]. This stops a public package of the same name from replacing yours. - A
[[tool.uv.index]]table with the name of a registered index and another URL is refused. Use the registered URL, or another name. This rule is checked at a deploy only, not before an execution: registering a name later does not stop a project that is deployed. It does not apply to a table whoseurlis a local folder, such as thelakanaindex that points atwheelhouse. index-urlandextra-index-urlmay not name a registered index. Declare it as a[[tool.uv.index]]table instead.
What the worker does with the credential
The worker reads the secret only when it builds an environment, and a deploy only checks that the
secret exists and holds a usable password. It gives the credential to uv as an index the worker
defines itself, bound to the scheme, host and port of the registered URL, so another host, or
another port of the same host, never receives it. A build that uses a credential runs uv sync
with --no-build, so only wheels are installed and no package's build script runs. The credential
is removed from everything uv prints: the password and user:password as given and percent-encoded, and the basic-auth encoding of the pair. It is not in the project's
directory, in the built environment, in uv's cache or in the task's process.
Commands
--output json (a global option of lakana, placed before the command) prints JSON instead of
text.
| Command | What it does | Needs |
|---|---|---|
lakana index add NAME --url URL [--credential-secret SECRET] |
Registers an index and prints Registered package index NAME. With --credential-secret it binds each unbound secret of that name to the index, in every environment that has one, which needs MANAGE_SECRET in each (otherwise the command is refused and binds nothing); a secret that is bound elsewhere, or that its environment uses for storage or webhooks, is refused. With text output it also prints, on standard error, how to finish: the lakana secret create ... --index NAME command for an environment where the secret does not exist yet, and the lakana secret bind command for one where it is not bound to this index. The hint is printed whenever a credential secret is named, and the command looks nothing up. |
MANAGE_PACKAGE_INDEX; MANAGE_SECRET in an environment to bind its unbound secret |
lakana index list |
Lists the indexes: name, URL and secret name. A narrow window leaves the secret name out and says so. | READ_ENVIRONMENT on any environment |
lakana index show NAME |
Shows one index with all its fields. | READ_ENVIRONMENT on any environment |
lakana index update NAME [--url URL] [--credential-secret SECRET] [--clear credential_secret] |
Changes the options given; --clear credential_secret removes the secret. The name cannot be changed, and the URL cannot be cleared. Prints Updated package index NAME. A new URL stops the deployed projects whose lock names the old one, as remove does, until the old URL is registered again or the projects are locked with the new one and deployed again. With a new URL, and with a name in --credential-secret, the secrets bound to the index are checked. |
MANAGE_PACKAGE_INDEX; also MANAGE_SECRET in the environment of each secret bound to the index, for a new URL |
lakana index remove NAME [--yes] |
Deletes the index after asking, and prints Deleted package index NAME. Projects whose lock names it stop running until it is registered again. Without a terminal, --yes is required. |
MANAGE_PACKAGE_INDEX |
SDK
client.package_indexes has these methods. The class of a result is
lakana._models.PackageIndex (the package does not export it by name):
| Method | Does |
|---|---|
create(name, url, *, credential_secret=None) |
Registers an index and returns it. |
list() |
Returns every index, sorted by name. |
get(name) |
Returns one index; raises NotFound for an unknown name. |
update(name, **changes) |
Changes url and credential_secret; credential_secret=None clears the secret. Any other keyword raises TypeError before a request is sent. |
delete(name) |
Deletes the index. |
A PackageIndex has the fields of the record: id, name, url, credential_secret,
created_by, created_at and updated_at.
import lakana
with lakana.Client() as client:
client.package_indexes.create(
"team", "https://pkgs.example.org/simple", credential_secret="team-index"
)
for index in client.package_indexes.list():
print(index.name, index.url, index.credential_secret)
REST
Five routes under /api/v1/package-indexes. The CLI and the SDK use only these.
| Method and path | Body | Answer |
|---|---|---|
POST /api/v1/package-indexes |
{"name", "url", "credential_secret"}; the secret is optional |
201 and the index |
GET /api/v1/package-indexes |
200 and a list of indexes, sorted by name |
|
GET /api/v1/package-indexes/{name} |
200 and the index |
|
PATCH /api/v1/package-indexes/{name} |
{"url"} and {"credential_secret"}, either or both; null clears the secret |
200 and the index |
DELETE /api/v1/package-indexes/{name} |
204 |
Status codes: 401 without a credential; 403 without the permission; 404 for an unknown name
when the caller may read; 409 for a taken name or URL; 422 for an invalid field, an unknown
field, a PATCH that changes nothing, a PATCH that names the name or clears the url.
Permissions and audit events
| Permission | Allows |
|---|---|
MANAGE_PACKAGE_INDEX |
Create, update and delete, and also list and show. It is an instance-wide permission. The ADMIN role has it, and it can be granted as a single action (see below). |
READ_ENVIRONMENT on any environment |
List and show, so that the author of a project sees which indexes exist and their URLs. |
A task token, the credential of a running task, is refused on every route, whatever its principal may do.
What a holder of MANAGE_PACKAGE_INDEX can do. An administrator can grant the permission on
its own, with lakana grant create and the action MANAGE_PACKAGE_INDEX at the scope of the
instance. Its holder can register an index at any URL, but a secret serves one use only: a package
index, a repository, or its environment. A holder of MANAGE_PACKAGE_INDEX alone can name only a
secret that is already bound to that same index. Naming an unbound secret, one bound elsewhere,
or one its environment uses for storage or webhooks is refused with 403; the refusal is
recorded as AUTHORIZATION_DENIED and the message names the exact lakana secret bind command
for someone who manages secrets to run. A holder of MANAGE_SECRET in the environment who names
an unbound secret binds it in the same step. Changing the URL of an index that has a bound
secret needs MANAGE_SECRET in that secret's environment, whatever credential the index names
at that moment, because the URL is where the secret goes. So a holder of MANAGE_PACKAGE_INDEX
cannot redirect the password of an environment's secret to a host of their choice. Grant the
permission only to people you would trust with the index's own credential. This stops the
redirection by an index manager, not code that runs in the worker: under the current runner a
person who may deploy and run Python code reads what the worker reads anyway
(see
Isolation: one instance is one trust domain).
MANAGE_REPOSITORY follows the same rules for a repository: its holder can name only a secret
bound to that repository and cannot move its URL while a secret is bound without
MANAGE_SECRET. A holder without DEPLOY can still stop projects: a changed URL and a removed
index both stop the deployed projects that use the index.
Three audit events record the changes. None holds a credential: only the name of the secret.
| Event | When | Detail |
|---|---|---|
PACKAGE_INDEX_CREATED |
An index is registered. | name, url, credential_secret |
PACKAGE_INDEX_UPDATED |
An index is changed. | name, changed (the field names), url and credential_secret after the change, previous (the value each changed field had before) |
PACKAGE_INDEX_DELETED |
An index is deleted. | name, url |