Architecture

The Pulp CLI architecture is described in this section.

Pulp Glue

Pulp CLI provides the pulp-glue library as an abstraction layer that lets you perform high-level operations in pulp. Its goal is to abstract interacting with the REST api by parsing the api docs, and waiting on tasks and task groups. It is shipped as a separate python package to allow broad use across multiple projects, such as pulp-squeezer and pulpcore. To this end, pulp-glue is the go-to place for all known version-dependent Pulp API subtleties and their corresponding fixes (see Version-dependent codepaths below).

OpenAPI

This is the part in pulp_glue that uses requests to perform low level communication with an openapi 3 compatible server.

Contexts

Pulp-glue provides the PulpContext encapsulating the OpenAPI object. You can use its call method to interact with any operation designated by its operation id. In addition, to perform specific operations on entities, glue ships a bunch of PulpEntityContext subclasses.

Deferred Api and Entity lookup

In order to be able to access every (sub-)command's help page, it is necessary that no code outside of the final performing command callback accesses the api property of the PulpContext. There are some facilities that perform deferred loading to help with that requirement. Those include:

  • PulpContext.api: When accessed, the api.json file for the addressed server will be read or downloaded and processed. Scheduled version checks will be reevaluated.
  • PulpContext.needs_version: This function can be used at any time to declare that an operation needs a plugin in a version range. The actual check will be performed when api was accessed for the first time, or immediately afterwards.
  • PulpEntityContext.entity: This property can be used to collect lookup attributes for entities by assigning dicts to it. On read access, the entity lookup will be performed through the api property.
  • PulpEntityContext.pulp_href: This property can be used to specify an entity by its URI. It will be fetched from the server only at read access.

Pulp CLI

Plugin System

The Pulp CLI is designed with a plugin structure. Plugins can either live in the pulp-cli package or be shipped independently. By convention, all parts of the CLI are packages in the open namespace pulpcore.cli. A plugin can register itself with the main app by specifying its main module as a pulp_cli.plugins entrypoint in setup.py.

entry_points={
    "pulp_cli.plugins": [
        "myplugin=pulpcore.cli.myplugin",
    ],
}

The plugin should then attach subcommands to the pulpcore.cli.common.main command by providing a mount method in the main module.

from pulpcore.cli.common.generic import pulp_command

@pulp_command()
def my_command():
    pass


def mount(main: click.Group, **kwargs: Any) -> None:
    main.add_command(my_command)

Contexts

In click, every subcommand is accompanied by a click.Context, and objects can be attached to them. In this CLI we attach a PulpCLIContext to the main command, which inherits from pulp-glue's PulpContext. This context handles the communication to the pulp server through its api property.

Further we encourage the handling of communication with certain endpoints by subclassing the PulpEntityContext or some of the resource-specific children, such as PulpRepositoryContext. Some examples of this can be found under pulp_glue/{plugin-name}/context.py.

By attaching them to the contexts of certain command groups, they are accessible to commands via the pass_entity_context decorator. Those entity contexts should provide a common interface to the layer of click commands that define the user interaction.

@pulp_group()
@pass_pulp_context
@click.pass_context
def my_command(ctx, pulp_ctx):
    ctx.obj = MyEntityContext(pulp_ctx)


@my_command.command()
@pass_entity_context
def my_sub_command(entity_ctx):
    ... href = ...
    entity_ctx.destroy(href)

Generics

For certain often repeated patterns like listing all entities of a particular kind, we provide generic commands that use the underlying context objects. The following example shows the use of the show_command generic.

from pulpcore.cli.common.generic import name_option, show_command,

lookup_params = [name_option]
my_command.add_command(show_command(decorators=lookup_params))

To add options to these subcommands, pass a list of PulpOption objects to the decorators argument. Preferably these are created using the pulp_option factory.

from pulpcore.cli.common.generic import list_command,

filter_params = [
    pulp_option("--name"),
    pulp_option("--name-contains", "name__contains"),
]
my_command.add_command(list_command(decorators=filter_params))

Version dependent code paths

Each Pulp CLI release is designed to support multiple Pulp server versions and the CLI itself is versioned independently of any version of the Pulp server components. It is supposed to be able to communicate with different combinations of server component versions at the same time. Because of this, it might be necessary to guard certain features and workarounds by checking against the available server plugin version.

As a rule of thumb, all necessary workarounds should be implemented in the corresponding Context objects. To facilitate diverting code paths depending on plugin versions, the PulpContext provides the needs_plugin and has_plugin methods, both of which accept a PluginRequirement object to describe dependencies on server components.

While has_plugin will evaluate immediately, needs_plugin can be seen as a deferred assertion. It will raise an error, once the first access to the server is attempted.

# In pulp_glue_my_plugin
class MyEntityContext(PulpEntityContext):
    def show(self, href):
        if self.pulp_ctx.has_plugin(PluginRequirement("my_content", specifier=">=1.2.3", inverted=True)):
            # Versioned workaroud
            # see bug-tracker/12345678
            return lookup_my_content_legacy(href)
        return super().show(href)


# In pulp_cli_my_plugin
@main.command()
@pass_pulp_context
@click.pass_context
def my_command(ctx, pulp_ctx):
    pulp_ctx.needs_plugin(PluginRequirement("my_content", specifier=">=1.0.0"))
    ctx.obj = MyEntityContext(pulp_ctx)

To declare version restrictions on options, the preprocess_entity method can be used to check if a given option is present in the request body and conditionally apply the requirements to the context.

In the following example, a guard is added because my_option was introduced to MyPluginRepository in version 3.24.0 of "my_plugin":

class PulpMyPluginRepositoryContext(PulpRepositoryContext):
    ...

    def preprocess_entity(self, body, partial) -> EntityDefinition:
        body = super().preprocess_entity(body, partial=partial)
        if "my_option" in body:
            self.pulp_ctx.needs_plugin(
                PluginRequirement("my_plugin", specifier=">=3.24.0", feature=_("my feature"))
            )
        return body

Note

The specifier >=x.y.z doesn't include x.y.z.dev according to PEP 440. Therefore, when adapting to an unreleased feature change from a plugin, you need to specify the prerelease part of the version explicitly. However >=x.y.z.dev is never unambiguous in the current Pulp versioning practice. Once that change is released please reset the constraint to the plain x.y.z schema.