Policy

The lunar_policy Python package provides utilities for working with Lunar policies, allowing you to load, query, and make assertions about component metadata, such as the component JSON.

For the reference documentation check out:

Installation

The package is available through pip:

pip install lunar-policy

Policy environment

Earthly Lunar executes policies in an environment set up with the following variables:

  • LUNAR_HUB_HOST: The host of the Lunar Hub.

  • LUNAR_HUB_INSECURE: Whether to skip SSL verification of the Lunar Hub.

  • LUNAR_POLICY_NAME: The name of the policy being executed.

  • LUNAR_INITIATIVE_NAME: The name of the initiative the policy belongs to.

  • LUNAR_POLICY_OWNER: The owner of the policy.

  • LUNAR_COMPONENT_ID: The ID of the component being checked in github.com/.../... format.

  • LUNAR_COMPONENT_DOMAIN: The domain of the component.

  • LUNAR_COMPONENT_OWNER: The owner of the component.

  • LUNAR_COMPONENT_PR: The PR number of the component, if applicable.

  • LUNAR_COMPONENT_GIT_SHA: The Git SHA of the component that the policy is being executed for.

  • LUNAR_COMPONENT_TAGS: The tags of the component.

  • LUNAR_COMPONENT_META: The metadata of the component as a JSON object.

  • LUNAR_BUNDLE_PATH: Used internally by Lunar to pass in the component JSON and the deltas to the policy.

  • Any secrets set in the Lunar Hub for the policy, via HUB_POLICY_SECRETS=<name>=<value>;....

Important

Note that since policies are re-evaluated frequently as each piece of data becomes available, it is strongly recommended to design your policy execution to be fast (no external API calls, no heavy processing, etc.). If you need to perform any expensive operations, consider using a collector instead, and passing the necessary data via the component JSON.

Important

Policies are re-evaluated between component data collections. This means that not all data is available from the beginning, and your policy should correctly report a NO_DATA status when it cannot make a decision yet. Much of this is handled automatically for you - see Handling Missing Data below for more details.

Core Components

The Policy SDK provides several key classes to help you write policies:

Check

The Check class provides a fluent interface for making assertions about policy data. It tracks accessed data within the component JSON for traceability purposes.

from lunar_policy import Check, Path

# Create a check with a name and optional description
with Check("my-check", "Validates important properties") as check:
    # Get data using JSONPath
    value = check.get(".path.to.data")
    
    # Make assertions
    check.assert_equals(Path(".api.endpoints[0].method"), "GET")
    check.assert_greater_or_equal(value, 50)
    check.assert_contains(Path(".api.endpoints[0].path"), "/")

๐Ÿ‘‰ For detailed reference, see the Check reference documentation.

Path

The Path class is used to pass JSONPath expressions to assertion methods of Check.

๐Ÿ‘‰ See the Path reference documentation.

NoDataError

The NoDataError exception is used to indicate that required data for a policy check is still pending (hasn't been collected yet).

๐Ÿ‘‰ For detailed reference, see the NoDataError reference documentation.

ComponentData

The ComponentData class is used to initialize a Check instance with component metadata from different sources. This can be useful for unit testing policies.

๐Ÿ‘‰ For detailed reference, see the ComponentData reference documentation.

Automatic Data Loading

When a policy is executed through Lunar Hub, Lunar passes in the relevant context via LUNAR_BUNDLE_PATH. This context includes the component JSON for the component that is being checked. When you create a Check instance, the library automatically loads this data under the hood when data is not provided.

Executing Policies Locally

To execute a policy locally for testing purposes, you can use the lunar CLI.

lunar policy dev --component-json path/to/component.json ./path/to/policy.py

If you would like to use the real component JSON of one of your components, you can do so via the command:

lunar policy dev --component github.com/my-org/my-repo ./path/to/policy.py

Check outcomes

Checks can result in the following possible outcomes:

  • PASS: The check passed successfully. All assertions within the check were satisfied.

  • FAIL: The check failed. One or more assertions within the check were not satisfied. This generally means that the developer working on the component needs to fix the issue.

  • NO_DATA: The check could not be completed due to data not being available yet. This is due collection not having finished yet (code or cron collectors are still running, or the CI is still running).

  • ERROR: The check encountered an error during execution. This indicates an unexpected error occurred during the check execution, such as a runtime exception. This is generally a bug either in the collection logic or in the policy code.

Handling Missing Data

Lunar makes a clear distinction between temporarily missing component JSON data (data is pending), permanently missing component JSON data, and failing component JSON data. This is needed under the hood to be able to provide partial policy results while collectors are still running. Policies that have enough data will provide accurate results, while policies that don't have enough data will often report a pending status.

Here is a breakdown of how the different statuses are determined:

  • Temporarily missing data (pending status): This is when some or all of the component JSON data required for assertions is not present, but the collector are still running. In this case, the check will report a pending status for now, thanks to methods like get, Path, exists and assert_exists automatically raising NoDataError when not enough data is available and collectors are still running.

  • Permanently missing data - sometimes expected on failures (fail status): This is when some of the component JSON data required for assertions is not present, and the collectors have finished running. assert_exists will report a failure in this case.

  • Permanently missing data - unexpected (error status): This is when some of the component JSON data required for assertions is not present, and the collectors have finished running. get or Path will report a ValueError after collectors finished, resulting in an error status.

  • Failing data (fail status): This is when the data is present, but the assertions (e.g. assert_equals, assert_true, etc.) fail.

This means that you should use get or Path to access data when you are assuming that the data will eventually be provided by a collector. Or use assert_exists (or exists within an if condition) to test for the existence of data if that's what the pass/fail outcome of the check should depend on.

Best Practices for Handling Missing Data

Below are examples demonstrating different approaches to handling missing data in policies:

Bad Approach: Blindly Assuming Data Exists

This approach incorrectly assumes all fields exist after retrieving an object, which can lead to errors when data is temporarily missing:

with Check("api-security-check") as check:
    # Bad: Gets the entire API object once and assumes all fields exist
    api = check.get(".api")
    
    # These will cause policy errors if api is None or missing expected fields
    check.assert_true(api["requires_auth"], "API should require authentication")
    rate_limit = api["rate_limit"]
    check.assert_equals(rate_limit, 100, f"API rate limit should be 100, but found {rate_limit}")
    check.assert_contains(api["security_headers"], "Content-Security-Policy")

Good Approach: Using Path and get for Automatic NoDataError Handling

This approach uses targeted field access and relies on the Check API to handle missing data automatically:

with Check("api-security-check") as check:
    # These assertions automatically raise NoDataError if paths don't exist yet:
    check.assert_true(Path(".api.requires_auth"), "API should require authentication")
    
    # get also raises NoDataError if the path doesn't exist yet
    rate_limit = check.get(".api.rate_limit") 
    check.assert_equals(rate_limit, 100, f"API rate limit should be 100, but found {rate_limit}")
    check.assert_contains(Path(".api.security_headers"), "Content-Security-Policy")

Good Approach, when needed: Using assert_exists or exists

In some cases, the test itself might be about the very existence of data. In this case, you can use assert_exists to test for that.

with Check("api-security-check") as check:
    check.assert_exists(".api.requires_auth", "API is missing requires_auth field")

The function assert_exists raises NoDataError (results in pending status) before collectors finished, and fails the check if the data is missing after collectors finished.

Another option is to rely on exists within an if condition.

with Check("api-security-check") as check:
    if check.exists(".api"):
        check.assert_true(Path(".api.requires_auth"), "API should require authentication")

In this case, if the the .api field is missing the assertion is not executed.

Writing Unit Tests for Policies

Let's assume that we have the following policy:

from lunar_policy import Check, ComponentData, Path

def verify_readme(data=None):
    check = Check("readme-long-enough", data=data)
    with check:
        lines = check.get('.readme.lines')
        check.assert_greater_or_equal(
            lines, 50,
            f'README.md should have at least 50 lines. Current count: {lines}'
        )
    return check

if __name__ == "__main__":
    verify_readme()

Here's an example showing how to write unit tests:

import unittest
from lunar_policy import Check, Path, ComponentData, CheckStatus

class TestReadmePolicy(unittest.TestCase):
    def test_not_long_enough(self):
        component_json = {
            "readme": {
                "lines": 49,
                "missing": False
            }
        }
        data = ComponentData.from_component_json(component_json)
        check = verify_readme(data)
        
        # Verify the check failed because the README isn't long enough
        self.assertEqual(check.status, CheckStatus.FAIL)
        self.assertEqual(check.failure_reasons[0], "README.md should have at least 50 lines. Current count: 49")

if __name__ == "__main__":
    unittest.main()

You can run the test with:

python -m unittest test_readme_policy.py

Last updated