Codelab: Implementing a platform feature

This codelab walks you through the process of implementing a new feature in the Fuchsia platform that can be conditionally included and configured by product or board configurations.

Prerequisites

This codelab assumes you are familiar with:

What you'll learn

  • How to decide if a feature belongs in the platform.
  • How to define feature flags in the Assembly configuration schema.
  • How to create and register an Assembly subsystem.
  • How to use subsystem APIs to include packages, configuration, and kernel arguments.
  • How to enable your new platform feature in a product or board.

Feature placement guidelines

A platform feature is always implemented in fuchsia.git and must be generic enough that it can be enabled on multiple products or boards. If a feature is specific to a single product or board, it does not belong in the platform.

The Fuchsia platform is the core, shared foundation for all products. Adding product-specific or board-specific features to the platform increases its size and complexity for all other products, and can create long-term maintenance burdens. It is critical to keep the platform generic.

Use the following guidelines to decide where to place a feature:

  • Platform feature: A feature that is useful to multiple products or boards. It should be generic and configurable, such as a new, optional networking service that different products can choose to include and configure.

  • Product feature: A feature that is specific to one product. This is often a feature that is visible to the end-user, such as a Fuchsia package that constructs the UI, or a unique set of fonts for a single product.

  • Board feature: A feature that is specific to one board's hardware, such as a driver for a specific hardware component, or configuration values (like GPIO pin numbers) that are unique to that board.

Codelab steps

Implementing a platform feature involves the following steps:

  1. Declare feature flags in config_schema.
  2. Define a new subsystem.
  3. Implement the subsystem logic.
  4. Enable the feature in a product/board.

Diagram showing how a platform feature is implemented

Figure 1. Implementing a platform feature and enabling it in a product config.

1. Declare feature flags in config_schema

Platform features are typically enabled conditionally based on flags set in the product or board configuration. The first step is to define the schema for these flags.

All platform feature flags are declared within Rust structs in //src/lib/assembly/config_schema. Each subsystem usually has its own file in this directory (e.g. fonts_config.rs or network_config.rs).

Example: For an example, follow these steps:

  1. To define a flag to enable a hypothetical "Tandem" networking feature, create a new file //src/lib/assembly/config_schema/src/platform_settings/tandem_config.rs:

    use serde::{Deserialize, Serialize};
    
    /// Configuration for the Tandem networking feature.
    #[derive(Debug, Deserialize, Serialize, PartialEq)]
    #[serde(default, deny_unknown_fields)]
    pub struct TandemConfig {
        /// Enables the core Tandem service.
        pub enabled: bool,
    
        /// Specifies the maximum number of concurrent connections.
        pub max_connections: u32,
    }
    
    // Choose reasonable default values.
    impl Default for TandemConfig {
        fn default() -> Self {
            Self {
                enabled: false,
                max_connections: 10,
            }
        }
    }
    

    Best practices for defaults:

    • Always use #[serde(default)] on the struct field within PlatformSettings (as shown in the next step).
    • Implement impl Default for your config struct (TandemConfig in this case) to define the default values for each field.
    • Avoid using field-level #[serde(default = "...")] in conjunction with #[derive(Default)] on the struct, as this can lead to inconsistent behavior.
  2. Add this new config struct to the main PlatformSettings in //src/lib/assembly/config_schema/src/platform_settings.rs:

    // ... other imports
    mod tandem_config;
    
    // ... other fields
    pub struct PlatformSettings {
        // ... other fields
        #[serde(default)]
        pub tandem: tandem_config::TandemConfig,
    }
    
  3. In a product configuration, you can now enable this new feature. For example:

    fuchsia_product_configuration(
        name = "my_product",
        product_config_json = {
            platform = {
                tandem = {
                    enabled = True,
                    max_connections = 20,
                },
            },
        },
    )
    

    Common Flag Patterns:

    Feature enabled in... When product config sets... OR When board config sets...
    All eng products platform.build_type = eng
    Specific products platform.tandem.enabled = True
    Products on capable boards provided_features = [ "tandem_hw" ]

2. Define a new subsystem

An assembly subsystem is a Rust module responsible for processing the configuration for a related group of platform features. It reads the flags defined in config_schema and uses Assembly builder APIs to include and configure the feature code.

Location: Subsystems are located in //src/lib/assembly/platform_configuration/src/subsystems.

For an example, follow these steps:

  1. Create a new file //src/lib/assembly/platform_configuration/src/subsystems/tandem.rs for our "Tandem" feature:

    use crate::subsystems::prelude::*;
    use assembly_config_schema::platform_config::tandem_config::TandemConfig;
    
    pub(crate) struct TandemSubsystem;
    impl DefineSubsystemConfiguration<TandemConfig> for TandemSubsystem {
        fn define_configuration(
            context: &ConfigurationContext<'_>,
            tandem_config: &TandemConfig,
            builder: &mut dyn ConfigurationBuilder,
        ) -> anyhow::Result<()> {
            if tandem_config.enabled {
                // Actions to include the feature will go here
                // See Step 3 for details
                println!("Tandem feature enabled with max_connections: {}", tandem_config.max_connections);
            }
            Ok(())
        }
    }
    

    Explanation:

    • The struct TandemSubsystem implements the DefineSubsystemConfiguration trait, typed with the TandemConfig struct we defined earlier.
    • The define_configuration function receives the ConfigurationContext, our specific TandemConfig, and a ConfigurationBuilder.
    • Inside this function, we check the enabled flag. If true, we'll use the builder to add the feature components to the system image.
  2. Add the module to //src/lib/assembly/platform_configuration/src/subsystems.rs:

    // ... other mods
    mod tandem;
    
  3. Call its define_configuration function within the main Subsystems::define_configuration function in the same file, passing the relevant part of the platform settings:

    // In Subsystems::define_configuration
    tandem::TandemSubsystem::define_configuration(
        context,
        &platform.tandem,
        builder,
    )?;
    

3. Implement the subsystem logic

Inside the define_configuration function of your subsystem, you'll use the ConfigurationBuilder methods to add the feature to the product based on the feature flags.

Updating tandem.rs, for example:

use crate::subsystems::prelude::*;
use assembly_config_schema::platform_config::tandem_config::TandemConfig;
use assembly_platform_configuration::{
    ConfigurationBuilder,
    KernelArg,
    ConfigValueType,
    Config,
    PackageSetDestination,
    PackageDestination,
    FileEntry,
    BuildType
};

pub(crate) struct TandemSubsystem;
impl DefineSubsystemConfiguration<TandemConfig> for TandemSubsystem {
    fn define_configuration(
        context: &ConfigurationContext<'_>,
        tandem_config: &TandemConfig,
        builder: &mut dyn ConfigurationBuilder,
    ) -> anyhow::Result<()> {

        if tandem_config.enabled {
            // 1. Add the feature's code at build time using an Assembly Input Bundle (AIB)
            builder.platform_bundle("tandem_core");

            // 2. Set a runtime configuration value
            builder.set_config_capability(
                "fuchsia.tandem.MaxConnections",
                Config::new(ConfigValueType::Int32, tandem_config.max_connections.into()),
            )?;

            // 3. Conditionally add a runtime kernel argument based on build type
            if context.build_type == &BuildType::Eng {
                builder.kernel_arg(KernelArg::TandemEngDebug);
            }

            // 4. Include a domain config package for more complex runtime configuration
            builder.add_domain_config(PackageSetDestination::Blob(PackageDestination::TandemConfigPkg))
                  .directory("config/data")
                  .entry(FileEntry {
                      source: "//path/to/tandem/configs:default.json".into(),
                      destination: "settings.json".into(),
                  })?;
        }

        Ok(())
    }
}

Build-time vs. runtime enablement:

  • Build-time: Artifacts are only included in the image if the feature is enabled. This is preferred for saving space, tightening security, enabling static analysis, and increasing performance for other products that do not need the feature.
  • Runtime: Artifacts are always included, but their behavior is controlled at runtime (e.g., by config values or kernel arguments).

Build time

Assembly organizes build-time features using Assembly Input Bundles (AIBs). A feature owner can insert many types of artifacts into a single AIB, and Assembly can be instructed when and how to add that AIB to a product. All AIBs are defined in //bundles/assembly/BUILD.gn. For example:

# Declares a new AIB with the name "tandem_core".
assembly_input_bundle("tandem_core") {
  # Include this package into the "base package set".
  # See RFC-0212 for an explanation on package sets.
  # The provided targets must be fuchsia_package().
  base_packages = [ "//path/to/my/tandem:pkg" ]

  # Include this file into BootFS.
  # The provided targets must be bootfs_files_for_assembly().
  bootfs_files_labels = [ "//path/to/my/tandem:bootfs" ]
}

To include the AIB, use the following method in your subsystem:

builder.platform_bundle("tandem_core");

If you add a new AIB, don't forget to add it to the appropriate list in //bundles/assembly/platform_aibs.gni, or you will get an error at build-time indicating that the AIB cannot be found.

Runtime

Assembly supports multiple types of runtime configuration. These types are listed in order of preference.

Config capabilities: A Fuchsia component can read the value of config capabilities at runtime, while Assembly sets the default value for those capabilities at build time, for example:

// Add a config capability named `fuchsia.tandem.MaxConnections` to the config package.
builder.set_config_capability(
    "fuchsia.tandem.MaxConnections",
    Config::new(ConfigValueType::Int32, tandem_config.max_connections.into()),
)?;

Assembly will add all default config capabilities to a config package in BootFS, therefore the capability will need to be routed from the /root component realm to your component.

Using platform-defined config capabilities in components

When a component needs to use a config capability that is defined and provided by the platform (via builder.set_config_capability in a subsystem), the component's CML file must include a use declaration. This declaration must specify the type of the configuration value, even though the value is provided by the parent realm.

Example component CML (my_component.cml):

{
    use: [
        {
            config: "fuchsia.tandem.MaxConnections", // The capability name
            from: "parent",
            key: "max_conn", // The key used in this component's structured config
            type: "int32",     // The type MUST be specified here
        },
    ],
    // ... other parts of the manifest
    config: {
        max_conn: { type: "int32" },
    },
}

Component Code:

Your component's source code must also be updated to expect this key in its structured configuration. This typically involves updating a struct that deserializes the config values, often generated by the ffx component config get command or a similar tool. Define a struct to deserialize the configuration:

// Example in the component's config.rs (e.g., src/config.rs)
use serde::Deserialize;

// This struct should match the keys and types in the CML 'config' block.
#[derive(Debug, Deserialize)]
pub struct TandemComponentConfig {
    pub max_conn: i32,
    // ... other config fields
}

Then, in your component's initialization code, retrieve the configuration:

let config = fuchsia_component::config::Config::take_from_startup_handle();
let tandem_config: TandemComponentConfig = config.get();

Key points:

  • The type (e.g., "bool", "int32", "string") MUST be included in the use stanza for platform-provided configs.
  • If the type is "string", you must also include max_size.
  • The key in the use stanza maps the capability to a field name in the component's own config schema, and thus to the field in the struct used to load the configuration in the component's code.
  • Ensure the component's code (e.g., Rust, C++) is updated to handle the new configuration key.

Domain configs: For complex configurations, lists of items, or those requiring custom types, domain configs are preferable to config capabilities. While it is often possible to "flatten" a complex configuration into a set of simple key-value pairs for config capabilities, this can become unwieldy.

For example, consider a component that needs a list of network endpoints, where each endpoint has a URL, a port, and a protocol. Using config capabilities, you might have to flatten this into a series of keys. For example:

// This approach is NOT recommended for lists or complex types.
"endpoint.0.url": "host1.example.com",
"endpoint.0.port": 443,
"endpoint.1.url": "host2.example.com",
"endpoint.1.port": 8080,

This becomes difficult to manage, especially if the number of endpoints is variable. A domain config is a much cleaner solution in this case. You can provide a single JSON file in a package that the component can parse at runtime:

// A domain config file (e.g., tandem_config.json)
{
  "endpoints": [
    {
      "url": "host1.example.com",
      "port": 443,
      "protocol": "HTTPS"
    },
    {
      "url": "host2.example.com",
      "port": 8080,
      "protocol": "HTTP"
    }
  ]
}

Domain configs are Fuchsia packages that provide a config file for your component to be read and parsed at runtime, for example:

// Create a new domain config in BlobFS with a file at "config/tandem_config.json".
builder.add_domain_config(PackageSetDestination::Blob(PackageDestination::TandemConfigPkg))
      .directory("config")
      .entry(FileEntry {
          source: config_src,
          destination: "tandem_config.json".into(),
      })?;

Your component must launch the domain config package as a child and use the directory, for example:

{
    children: [
        {
            name: "tandem-config",
            url: "fuchsia-pkg://fuchsia.com/tandem-config#meta/tandem-config.cm",
        },
    ],
    use: [
       {
            directory: "config",
            from: "#tandem-config",
            path: "/config",
        },
    ],
}

Kernel argument: A kernel argument is only used for enabling kernel features. Assembly constructs a command line to pass to the kernel at runtime, for example:

builder.kernel_arg(KernelArg::TandemEngDebug);

4. Enable the feature in a product/board

Once your subsystem is implemented, you can enable the feature by setting the flags you defined in Step 1 in a product or board configuration file.

Enable in product config

To enable the "Tandem" feature for a specific product, modify its fuchsia_product_configuration target (usually in a BUILD.bazel file):

fuchsia_product_configuration(
    name = "my_product",
    product_config_json = {
        platform = {
            # ... other platform settings
            tandem = {
                enabled = True,
                max_connections = 20,
            },
        },
    },
    # ... other attributes
)

Enable based on board features

Board features are a way for a board to declare that they support a particular piece of hardware. Platform subsystems can read board context.board_config.provided_features to conditionally enable or disable features.

If your subsystem logic checks for board features, ensure the board configuration (e.g., //boards/my_board/BUILD.bazel) includes it. For example:

fuchsia_board_configuration(
    name = "my_board",
    provided_features = [
        "tandem_hw",  # This will be seen by context.board_config.provided_features
    ],
    # ...
)

After modifying the configuration, rebuilding the product bundle will include the Tandem feature and its configurations as defined in your subsystem.