Zero-rate a service project
This guide takes the project an OpenStack service keeps its own resources in, octavia's amphorae for instance, off what a billing run invoices. It registers the project, and then either bills it at zero through a membership that carries a full discount or bills it under a project of the operator. Why those resources are metered at all is in resources a service creates for itself.
Before you start
- A cloud whose events already reach the Reporting API, through the collector or a sync.
- The id keystone gives the service project. Kolla calls the project
service:openstack project show service -f value -c id. - The Reporting API reachable, and an API token for it. Which credential each route takes is on the Reporting API endpoints page.
- The reporting database reachable from the machine you run the CLI on, and
tally-reporting-adminat the version the API runs. Its subcommands are on the reporting admin CLI page, and the Reporting API settings page namesTALLY_REPORTING_DB_URL. tally-engineandjqon the machine, for the check.
Find what the service project holds
List the resources the cloud's events booked to the service project:
httpGET /api/v1/resources?cloud=os-prod-eu1&project_id=4f6a8c0e2b4d4f6a8c0e2b4d4f6a8c0e&status=allitemsholds one row per resource, deleted ones included, and anext_cursorthat is not null means there are more pages. An amphora is aninstancewhosesize.flavoris the flavor octavia boots amphorae from,amphoraon a Kolla deployment, and its boot volume is avolume. An emptyitemsmeans nothing was ever booked to that project, and this guide has nothing to change. Withoutstatus=allthe answer leaves out the deleted resources, and a run still bills the hours they ran in its month.
Register the service project
Register the project under the id keystone gives it:
httpPOST /api/v1/projects Content-Type: application/json {"platform": "openstack", "cloud": "os-prod-eu1", "external_id": "4f6a8c0e2b4d4f6a8c0e2b4d4f6a8c0e", "name": "service"}The answer is 201, and its
idis the registry id the relations below name,2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6din this guide. A 409 means the registry already holds the pair, and this call reads the id back:httpGET /api/v1/projects?cloud=os-prod-eu1&external_id=4f6a8c0e2b4d4f6a8c0e2b4d4f6a8c0e
Bill the service project at zero
Point the admin CLI at the reporting database and register the meta-project the service project becomes a member of. The id printed alone on stdout is the relation's target:
shexport TALLY_REPORTING_DB_URL='postgres://tally:password@db.internal:5432/tally_reporting?sslmode=require' tally-reporting-admin create-meta-project \ --external-id internal-usage \ --name "Internal usage"text8c9d0e1f-2a3b-4c4d-8e5f-6a7b8c9d0e1fAn external id the registry already holds is refused, and the CLI exits 1.
Relate the service project to the meta-project. The path's
{id}is the service project's registry id:httpPOST /api/v1/projects/{id}/relations Content-Type: application/json { "target_id": "8c9d0e1f-2a3b-4c4d-8e5f-6a7b8c9d0e1f", "relation_type": "member_of", "valid_from": "2026-10-01T00:00:00Z", "metadata": { "pricing_adjustments": [ {"type": "project_discount", "rate": "1", "scope": "all", "description": "Service project, not invoiced"} ] } }The rate is a string, never a number.
valid_fromis the first instant of the earliest month that is not finalized yet. Left out, it is the instant of the call, which covers the running month and none before it. A finalized month stays as it was billed. Changing the rate later means closing the relation and creating a successor, as change the rate for a later month shows.
Bill the service project under an operator project instead
This section replaces the one before it: an attributed project has no statement of its own, so a discount on its membership reaches nothing.
Create the relation from the project that pays. The path's
{id}is the registry id of the registered project that pays, the operator's own:httpPOST /api/v1/projects/{id}/relations Content-Type: application/json { "target_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d", "relation_type": "infrastructure_tenant", "valid_from": "2026-10-01T00:00:00Z" }The target is the service project's registry id. The service project's line items then appear as
related_costson the statement of the project that pays.
Check the result
Run the month (run a period) and export the run (export a run). A service project billed at zero has this statement:
shjq '{base_cost, adjustments, net_cost, total}' \ ./2026-10/json/statement-os-prod-eu1%2F4f6a8c0e2b4d4f6a8c0e2b4d4f6a8c0e.jsonjson{ "base_cost": 38.31, "adjustments": [ { "type": "project_discount", "relation_type": "member_of", "relation_target": "internal-usage", "relation_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "scope": "all", "description": "Service project, not invoiced", "rate": 1.000000, "base": 38.31, "amount": -38.31 } ], "net_cost": 0.00, "total": 0.00 }The line items stay on the statement, the one adjustment takes the whole base cost off, and
net_costandtotalare 0.00.Check that the run no longer reports the project as unregistered:
shjq '[.stats.unregistered_projects // [] | .[] | select(.project_id == "4f6a8c0e2b4d4f6a8c0e2b4d4f6a8c0e")] | length' ./2026-10/json/run.jsontext0Where the service project is billed under an operator project, the run's index lists no statement for it:
shjq '[.statements[] | select(.project_id == "4f6a8c0e2b4d4f6a8c0e2b4d4f6a8c0e")] | length' ./2026-10/json/run.jsontext0The statement of the project that pays carries one entry under
related_costswhoserelation_typeisinfrastructure_tenantand whoseproject_idis the service project's id.