diff options
Diffstat (limited to 'MIGRATING-A-DH-PLUGIN.md')
-rw-r--r-- | MIGRATING-A-DH-PLUGIN.md | 536 |
1 files changed, 536 insertions, 0 deletions
diff --git a/MIGRATING-A-DH-PLUGIN.md b/MIGRATING-A-DH-PLUGIN.md new file mode 100644 index 0000000..3e4e227 --- /dev/null +++ b/MIGRATING-A-DH-PLUGIN.md @@ -0,0 +1,536 @@ +# Migrating a `debhelper` plugin to `debputy` + +_This is [how-to guide] and is primarily aimed at getting a task done._ + +<!-- To writers and reviewers: Check the documentation against https://documentation.divio.com/ --> + +This document will help you convert a `debhelper` plugin / `debhelper` tool into a `debputy` plugin. +Prerequisites for this how-to guide: + + * You have a `debhelper` tool/plugin that you want to migrate. Ideally a simple one as not all tools + can be migrated at this time. + * Many debhelper tools do not come with test cases, because no one has created a decent test framework + for them. Therefore, consider how you intend to validate that the `debputy` plugin does not have any + (unplanned) regressions compared to `debhelper` tool. + * Depending on the features needed, you may need to provide a python hook for `debputy` to interact + with. + - Note: `debputy` will handle byte-compilation for you per + [Side note: Python byte-compilation](#side-note-python-byte-compilation) + +Note that during the conversion, you may find that `debputy` cannot support the requirements for your +debhelper tool for now. Feel free to file an issue for what is holding you back in the +[debputy issue tracker]. + +Prerequisites +------------- + +This guide assumes familiarity with Debian packaging and the debhelper tool stack in general. Notably, +you are expected to be familiar with the `Dh_Lib.pm` API to the point of recognising references to said +API and how to look up document for methods from said API. + +If the debhelper tool is not written in `Dh_Lib.pm`, then you will need to understand how to map the +`Dh_Lib.pm` reference into the language/tool equivalent on your own. + +## Step 0: The approach taken + +The guide will assume you migrate one tool (a `dh_foo` command) at a time. If you have multiple tools +that need to migrate together, you may want to review "Step 1" below for all tools before migrating to +further steps. + +## Step 1: Analyze what features are required by the tools and the concept behind the helper + +For the purpose of this guide, we can roughly translate debhelper tools into one or more +of the following categories. + + +### Supported categories + + * Install `debian/pkg.foo` *as-is* into a directory. + - This category uses a mix of `pkgfile` + `install_dir` + `install_file` / `install_prog` + - Example: `dh_installtmpfiles` + * If some file is installed in or beneath a directory, then (maybe) analyze the file, and apply metadata + (substvars, maintscripts, triggers, etc.). Note this does *not* apply to special-case of services. + While services follow this pattern, `debputy` will have special support for services. + - Typically, this category uses a bit of glob matching + (optionally) `open` + + `addsubstvars` / `autoscript` / `autotrigger` + - Example: `dh_installtmpfiles` + - *Counter* examples: `dh_installsystemd` (due to service rule, below). + +### Unsupported categories + + * Read `debian/pkg.foo` and do something based on the content of said file. + - Typically, the category uses a mix of `pkgfile` + `filedoublearray` / `filearray` / `open(...)`. + The most common case of this is to install a list of files in the `debian/pkg.foo` file. + - In this scenario, the migration strategy should likely involve replacing `debian/pkg.foo` with + a section inside the `debian/debputy.manifest` file. + - Example: `dh_install` + * Any tool that manages services like `systemd`, `init.d` or `runit`. + - Typically, this category uses a bit of glob matching + (optionally) `open` + + `addsubstvars` / `autoscript` / `autotrigger`. + - This is unsupported because services will be a first-class feature in `debputy`, but the feature + is not fully developed yet. + - Example: `dh_installsystemd` + * Based on a set of rules, modify a set of files if certain criteria are met. + - Example: `dh_strip`, `dh_compress`, `dh_dwz`, `dh_strip_nondeterminism`, `dh_usrlocal` + * Run custom build system logic that cannot or has not been fit into the `debhelper` Buildsystem API. + - Example: `dh_cmake_install`, `dh_raku_build`, etc. + * "None of the above". There are also tools that have parts not fitting into any of the above + - Which just means the guide has no good help to offer you for migrating. + - Example: `dh_quilt_patch` + +As mentioned, a tool can have multiple categories at the same time. As an example: + + * The `dh_installtmpfiles` tool from debhelper is a mix between "installing `debian/pkg.tmpfiles` in to + `usr/lib/tmpfiles.d`" and "Generate a maintscript based on `<prefix>/tmpfiles.d/*.conf` globs". + + * The `dh_usrlocal` tool from debhelper is a mix between "Generate a maintscript to create dirs in + `usr/local` as necessary on install and clean up on removal" and "Remove any directory from `usr/local` + installed into the package". + + +When migrating a tool (or multiple tools), it is important to assert that all categories are supported by +the `debputy` plugin API. Otherwise, you will end with a half-finished plugin and realize you cannot +complete the migration because you are missing a critical piece that `debputy` currently do not support. + +If your tool does not fit inside those two base categories, you cannot fully migrate the tool. You should +consider whether it makes sense to continue without the missing features. + +## Step 2: Setup basic infrastructure + +This how-to guide assumes you will be using the debhelper integration via `dh-sequence-installdebputy`. To +do that, add `dh-sequence-installdebputy` in the `Build-Depends` in `debian/control`. With this setup, +any `debputy` plugin should be provided in the directory `debian/<package>.debputy-plugins` (replace `<package>` +with the name of the package that should provide the plugin). + +In this directory, for each plugin, you can see the following files: + + debian/package.debputy-plugins/my-plugin.json # Metadata file (mandatory) + debian/package.debputy-plugins/my_plugin.py # Python implementation (optional, note "_" rather than "_") + debian/package.debputy-plugins/my_plugin_check.py # tests (optional, run with py.test, note "_" rather than "_") + # Alternative names such as _test.py or _check_foo.py works too + +A basic version of the JSON plugin metadata file could be: + + +```json +{ + "plugin-initializer": "initialize_my_plugin", + "api-compat-level": 1, + "packager-provided-files": [ + { + "stem": "foo", + "installed-path": "/usr/share/foo/{name}.conf" + } + ] +} +``` + +This example JSON assumes that you will be providing both python code (`plugin-intializer`, requires a Python +implementation file) and packager provided files (`packager-provided-files`). In some cases, you will *not* +need all of these features. Notably, if you find that you do not need any feature requiring python code, +you are recommended to remove `plugin-initializer` from the plugin JSON file. + +A Python-based plugin for `debputy` plugin starts with an initialization function like this: + +```python +from debputy.plugin.api import DebputyPluginInitializer + +def initialize_my_plugin(api: DebputyPluginInitializer): + pass +``` + +Remember to replace the values in the JSON, so they match your plugin. The keys are: + + * `plugin-initializer`: (Python plugin-only) The function `debputy` should call to initialize your plugin. This is + the function we just defined in the previous example). The plugin loader requires this initialization function to + be a top level function of the module (that is, `getattr(module, plugin_initializer)` must return the initializer + function). + * `module`: (Python plugin-only, optional) The python module the `plugin-initializer` function is defined in. + If omitted, `debputy` will derive the module name from the plugin name (replace `-` with `_`). When omitted, + the Python module can be placed next to the `.json` file. This is useful single file plugins. + * `api-compat-level`: This is the API compat level of `debputy` required to load the + plugin. This defines how `debputy` will load the plugin and is to ensure that + `debputy`'s plugin API can evolve gracefully. For now, only one version is supported + and that is `1`. + * `packager-provided-files`: Declares packager provided files. This keyword is covered in the section below. + +This file then has to be installed into the `debputy` plugin directory. + +With this you have an empty plugin that `debputy` can load, but it does not provide any features. + +## Step 3: Provide packager provided files (Category 1 tools) + +*This step only applies if the tool in question automatically installs `debian/pkg.foo` in some predefined path +like `dh_installtmpfiles` does. If not, please skip this section as it is not relevant to your case.* + +You can ask `debputy` to automatically detect `debian/pkg.foo` files and install them into a concrete directory +via the plugin. You have two basic options for providing packager provided files. + + 1) A pure-JSON plugin variant. + 2) A Python plugin variant. + +This guide will show you both. The pure-JSON variant is recommended assuming it satisfies your needs as it is +the simplest to get started with and have fewer moving parts. The Python plugin has slightly more features +for the "1% special cases". + +### Packager provided files primer on naming convention + +This section will break the filename `debian/g++-3.0.name.segment.my.file.type.amd64` down into parts and name +the terms `debputy` uses for them and how they are used. If you already know the terms, you can skip this section. + +This example breaks into 4 pieces, in order: + + * An optional package name (`g++-3.0`). Decides which package the file applies to (defaulting to the main package + if omitted). It is also used as the default "installed as name". + + * An optional "name segment" (`name.segment`). Named so after the `--name` parameter from `debhelper` that is + needed for `debhelper` to detect files with the segment and because it also changes the default "installed as + name" (both in `debhelper` and `debputy`). When omitted, the package name decides the "installed as name". + + * The "stem" (`my.file.type`). This part never had an official name in `debhelper` other than `filename` + or `basename`. + + * An optional architecture restriction. It is used in special cases like `debian/foo.symbols.amd64` where you + have architecture specific details in the file. + +In `debputy`, when you register a packager provided file, you have some freedom in which of these should apply +to your file. The architecture restriction is rarely used and disabled by default, whereas the "name segment" +is available by default. When the "name segment" is enabled, the packager is able to: + + 1) choose a different filename than the package name (by using `debian/package.desired-name.foo` instead of + `debian/package.foo`) + + 2) provide multiple files for the same package (`debian/package.foo` *and* `debian/package.some-name.foo`). + +If it is important that a package can provide at most one file, and it must be named after the package itself, +you are advised to disable to name segment. + +### JSON-based packager provided files (Category 1 tools) + +With the pure JSON based method, the plugin JSON file should contain all the relevant details. A minimal +example is: + +```json +{ + "api-compat-level": 1, + "packager-provided-files": [ + { + "stem": "foo", + "installed-path": "/usr/share/foo/{name}.conf", + "reference-documentation": { + "description": "Some possibly multi-line description related to foo", + "format-documentation-uris": ["man:foo.conf(5)"] + } + } + ] +} +``` +(This file should be saved as `debian/package.debputy-plugins/my-plugin.json`.) + +This plugin snippet would provide one packager provided files and nothing else. When loading the plugin, `debputy` +would detect files such as `debian/package.foo` and install them into `/usr/share/foo/package.conf`. + +As shown in the example. the packager provided files are declared as a list in the attribute +`packager-provided-files`. Each element in that list is an object with the following keys: + + * `name` (required): The "stem" of the file. In the example above, `"foo"` is used meaning that `debputy` + would detect `debian/package.foo`. Note that this value must be unique across all packager provided files known + by `debputy` and all loaded plugins. + + * `installed-path` (required): A format string describing where the file should be installed. This is + `"/usr/share/foo/{name}.conf"` from the example above and leads to `debian/package.foo` being installed + as `/usr/share/foo/package.conf`. + + The following placeholders are supported: + + * `{name}` - The name in the name segment (defaulting the package name if no name segment is given) + * `{priority}` / `{priority:02}` - The priority of the file. Only provided priorities are used (that + is, `default-priority` is provided). The latter variant ensuring that the priority takes at least + two characters and the `0` character is left-padded for priorities that takes less than two + characters. + * `{owning_package}` - The name of the package. Should only be used when `{name}` alone is insufficient. + If you do not want the "name" segment in the first place, set `allow-name-segment` to `false` instead. + + The path is always interpreted as relative to the binary package root. + + * `default-mode` (optional): If provided, it must be an octal mode (such as `"0755"`), which defines the mode + that `debputy` will use by default for this kind of file. Note that the mode must be provided as a string. + + * `default-priority` (optional): If provided, it must be an integer declaring the default priority of the file, + which will be a part of the filename. The `installed-path` will be required to have the `{priority}` or + `{priority:02}` placeholder. This attribute is useful for directories where the files are read in "sorted" + and there is a convention of naming files like `20-foo.conf` to ensure files are processed in the correct + order. + + * `allow-name-segment` (optional): If provided, it must be a boolean (defaults to `true`), which determines + whether `debputy` should allow a name segment for the file. + + * `allow-architecture-segment` (optional): If provided, it must be a boolean (defaults to `false`), which determines + whether `debputy` should allow an architecture restriction for the file. + + * `reference-documentation` (optional): If provided, the following keys can be used: + + * `description` (optional): If provided, it is used as a description for the file if the user requests + documentation about the file. + + * `format-documentation-uris` (optional): If provided, it must be a list of URIs that describes the format + of the file. `http`, `https` and `man` URIs are recommended. + + +### Python-based packager provided files (Category 1 tools) [NOT RECOMMENDED] + +**This section uses a Python-based API, which is not recommended at this time as the logistics are not finished** + +With the Python based method, the plugin JSON file should contain a reference to the python module. A minimal +example is: + +```json +{ + "api-compat-level": 1, + "plugin-initializer": "initialize_my_plugin" +} +``` +(This file should be saved as `debian/package.debputy-plugins/my-plugin.json`.) + +The python module file should then provide the `initialize_my_plugin` function, which could look something like this: + +```python +from debputy.plugin.api import DebputyPluginInitializer + +def initialize_my_plugin(api: DebputyPluginInitializer): + api.packager_provided_file( + "foo", # This is the "foo" in "debian/pkg.foo" + "/usr/share/foo/{name}.conf", # This is the directory to install them at. + ) +``` +(This file would be saved as `debian/package.debputy-plugins/my_plugin.py` assuming `my-plugin.json` was +used for the metadata file) + +This example code would make `debputy` install `debian/my-pkg.foo` as `/usr/share/foo/my-pkg.conf` provided the +plugin is loaded. Please review the API docs for the full details of options. + +This can be done via the interactive python shell with: + +```python +import sys +sys.path.insert(0, "/usr/share/dh-debputy/") +from debputy.plugin.api import DebputyPluginInitializer +help(DebputyPluginInitializer.packager_provided_file) +``` + +### Testing your plugin + +If you are the type that like to provide tests for your code, the following `py.test` snippet can get you started: + +```python +from debputy.plugin.api.test_api import initialize_plugin_under_test + + +def test_packager_provided_files(): + plugin = initialize_plugin_under_test() + ppf_by_stem = plugin.packager_provided_files_by_stem() + assert ppf_by_stem.keys() == {'foo'} + foo_file = ppf_by_stem['foo'] + + assert foo_file.stem == 'foo' + + # Note, the discard part is the installed into directory, and it is skipped because `debputy` + # normalize the directory as an implementation detail and the test would depend on said detail + # for no good reason in this case. If your case have the variable in the directory part, tweak + # the test as necessary. + _, basename = foo_file.compute_dest("my-package") + assert basename == 'my-package.conf' + # Test other things you might have configured: + # assert foo_file.default_mode == 0o755 # ... if the file is to be executable + # assert foo_file.default_priority == 20 # ... if the file has priority + # ... +``` +(This file would be saved as `debian/package.debputy-plugins/my_plugin_check.py` assuming `my-plugin.json` was +used for the metadata file) + +This test works the same regardless of whether the JSON-based or Python-based method was chosen. + +## Step 4: Migrate metadata detection (Category 3 tools) [NOT RECOMMENDED] + +*This step only applies if the tool in question generates substvars, maintscripts or triggers based on +certain paths being present or having certain content like `dh_installtmpfiles` does. However, +this section does **NOT** apply to service management tools (such as `dh_installsystemd`). If not, please +skip this section as it is not relevant to your case.* + +For dealing with substvars, maintscripts and triggers, the plugin will need to register a function that +can perform the detection. The `debputy` API refers to it as a "detector" and functionally it behaves like +a "callback" or "hook". The "detector" will be run once per package that it applies to with some context and +is expected to register the changes it wants. + +A short example is: + +```python +from debputy.plugin.api import ( + DebputyPluginInitializer, + VirtualPath, + BinaryCtrlAccessor, + PackageProcessingContext, +) + + +def initialize_my_plugin(api: DebputyPluginInitializer): + # ... remember to preserve any existing code here that you may have had from previous steps. + api.metadata_or_maintscript_detector( + "foo-detector", # This is an ID of the detector. It is part of the plugins API and should not change. + # Packagers see it if it triggers an error and will also be able to disable by this ID. + detect_foo_files, # This is the detector (hook) itself. + ) + + +def detect_foo_files(fs_root: VirtualPath, + ctrl: BinaryCtrlAccessor, + context: PackageProcessingContext, + ) -> None: + # If for some reason the hook should not apply to all packages, and `metadata_or_maintscript_detector` does not + # provide a filter for it, then you just do an `if <should not apply>: return` + if not context.binary_package.is_arch_all: + # For some reason, our hook only applies to arch:all packages. + return + + foo_dir = fs_root.lookup("usr/share/foo") + if not foo_dir: + return + + conf_files = [path.absolute for path in foo_dir.iterdir if path.is_file and path.name.endswith(".conf")] + if not conf_files: + return + ctrl.substvars.add_dependency("misc:Depends", "foo-utils") + conf_files_escaped = ctrl.maintscript.escape_shell_words(*conf_files) + # With multi-line snippets, consider: + # + # snippet = textwrap.dedent("""\ + # ... content here using {var} + # """).format(var=value) + # + # (As that tends to result in more readable snippets, when the dedent happens before formatting) + snippet = f"foo-analyze --install {conf_files_escaped}" + ctrl.maintscript.on_configure(snippet) +``` +(This file would be saved as `debian/package.debputy-plugins/my_plugin.py`) + +This code would register the `detect_foo_files` function as a metadata hook. It would be run for all regular `deb` +packages processed by `debputy` (`udeb` requires opt-in, auto-generated packages such as `-dbgsym` cannot be +targeted). + +The hook conditionally generates a dependency (via the `${misc:Depends}` substvar) on `foo-utils` and a `postinst` +snippet to be run when the package is configured. + +An important thing to note is that `debputy` have *NOT* materialized the package anywhere. Instead, `debputy` +provides an in-memory view of the file system (`fs_root`) and related path metadata that the plugin should base its +analysis of. The in-memory view of the file system can have virtual paths that are not backed by any real +path on the file system. This commonly happens for directories and symlinks - and during tests, also for files. + + +In addition to the python code above, remember that the plugin JSON file should contain a reference to the python +module. A minimal example for this is: + +```json +{ + "api-compat-level": 1, + "plugin-initializer": "initialize_my_plugin" +} +``` +(This file should be saved into `debian/package.debputy-plugins/my-plugin.json` assuming `my_plugin.py` was +used for the module file) + + +If you are the type that like to provide tests for your code, the following `py.test` snippet can get you started: + +```python +from debputy.plugin.api.test_api import initialize_plugin_under_test, build_virtual_file_system, \ + package_metadata_context + + +def test_packager_provided_files(): + plugin = initialize_plugin_under_test() + detector_id = 'foo-detector' + + fs_root = build_virtual_file_system([ + '/usr/share/foo/foo.conf' # Creates a virtual (no-content) file. + # Use virtual_path_def(..., fs_path="/path") if your detector needs to read the file + # NB: You have to create that "/path" yourself. + ]) + + metadata = plugin.run_metadata_detector( + detector_id, + fs_root, + # Test with an arch:any package. The test framework will supply a minimum number of fields + # (e.g., "Package") so you do not *have* to provide them if you do not need them. + # That is also why providing `Architecture` alone works here. + context=package_metadata_context(package_fields={'Architecture': 'any'}) + ) + # Per definition of our detector, there should be no dependency added (even though the file is there) + assert 'misc:Depends' not in metadata.substvars + # Nor should any maintscripts have been added + assert metadata.maintscripts() == [] + + metadata = plugin.run_metadata_detector( + detector_id, + fs_root, + # This time, we test with an arch:all package + context=package_metadata_context(package_fields={'Architecture': 'all'}) + ) + + assert metadata.substvars['misc:Depends'] == 'foo-utils' + + # You could also have added `maintscript='postinst'` to filter by which script it was added to. + snippets = metadata.maintscripts() + # There should be exactly one snippet + assert len(snippets) == 1 + snippet = snippets[0] + # And we can verify that the snippet is as expected. + assert snippet.maintscript == 'postinst' + assert snippet.registration_method == 'on_configure' + assert 'foo-analyze --install /usr/share/foo/foo.conf' in snippet.plugin_provided_script +``` +(This file should be saved into `debian/package.debputy-plugins/my_plugin_check.json` assuming `my_plugin.py` was +used for the module file) + +This test works the same regardless of whether the JSON-based or Python-based method was chosen. + +## Step 4: Have your package provide `debputy-plugin-X` + +All third-party `debputy` plugins are loaded by adding a build dependency on `debputy-plugin-X`, +where `X` is the basename of the plugin JSON file. Accordingly, any package providing a `debputy` plugin +must either be named `debputy-plugin-X` or provide `debputy-plugin-X` (where `X` is replaced with the concrete +plugin name). + +## Step 5: Running the tests + +To run the tests, you have two options: + + 1) Add `python3-pytest <!nocheck>` to the `Build-Depends` in `debian/control`. This will cause + `dh_installdebputy` to run the tests when the package is built. The tests will be skipped + if `DEB_BUILD_OPTIONS` contains `nocheck` per Debian Policy. You will also need to have + the `debputy` command in PATH. This generally happens as a side effect of the + `dh-sequence-installdebputy` build dependency. + + 2) Add `autopkgtest-pkg-debputy` to the `Testsuite` field in `debian/control`. This will cause + the Debian CI framework (via the `autodep8` command) to generate an autopkgtest that will + run the plugin tests against the installed plugin. + +Using both options where possible is generally preferable. + +If your upstream uses a Python test framework that auto-detects tests such as `py.test`, you may +find that it picks up the `debputy` plugin or its tests. If this is causing you issues, please have +a look at the `dh_installdebputy` manpage, which have a section dedicated to how to resolve these +issues. + +## Side note: Python byte-compilation + +When you install a `debputy` plugin into `/usr/share/debputy/debputy/plugins`, then `debputy` will +manage the Python byte-compilation for you. + +## Closing + +You should now either have done all the basic steps of migrating the debhelper tool to `debputy` +or discovered some feature that the guide did not cover. In the latter case, please have a look +at the [debputy issue tracker] and consider whether you should file a feature request for it. + +[how-to guide]: https://documentation.divio.com/how-to-guides/ +[debputy issue tracker]: https://salsa.debian.org/debian/debputy/-/issues |