Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
// under the License.
package com.cloud.upgrade.dao;

import java.util.List;

import com.cloud.upgrade.dao.VersionVO.Step;
import com.cloud.utils.db.GenericDao;

public interface VersionDao extends GenericDao<VersionVO, Long> {
VersionVO findByVersion(String version, Step step);

String getCurrentVersion();

List<VersionVO> getAllVersions();
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,13 @@ public String getCurrentVersion() {
}

}

@Override
@DB
public List<VersionVO> getAllVersions() {
SearchCriteria<VersionVO> sc = AllFieldsSearch.create();
sc.setParameters("step", Step.Complete);

return listBy(sc);
}
}
94 changes: 94 additions & 0 deletions reporter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

# CloudStack Usage Reporter

This directory contains the server-side webservice for the Apache CloudStack usage reporting feature. When enabled, CloudStack management servers periodically send an anonymized report to the Apache CloudStack project. This data helps the community understand how CloudStack is deployed and used in the field.

All data collected is anonymous. No personally identifiable information, IP addresses, or workload data is transmitted.

## Enabling usage reporting

Usage reporting is configured through CloudStack's Global Settings. Two settings are available:

| Setting | Default | Description |
|---|---|---|
| `usage.report.interval` | `0` (disabled) | Interval in days between reports. Set to `7` to enable weekly reporting. Changing this setting requires a restart of the Management Server. |
| `usage.report.uri` | `https://reporting.cloudstack.org/report` | The endpoint reports are sent to. Only HTTPS is supported. |

## The webservice

The collector is a Python Flask application (`usage-report-collector.py`) that receives reports and stores them as JSON files on the local filesystem. It exposes a single endpoint:

```
POST /report/<unique_id>
```

The `unique_id` is a SHA-256 hash derived from the management server's database, ensuring reports from the same installation can be correlated across time without identifying the operator.

### Storage

Reports are stored below a base directory, configurable through the `REPORT_DIR` environment variable (default: `reports` in the working directory). A directory is created per `unique_id` and each report is stored with its receive timestamp as the filename:

```
reports/
<unique_id>/
2026-08-07T09-15-04Z.json
2026-08-14T09-15-11Z.json
```

### Validation

To keep malicious or malformed submissions out, the collector rejects reports that are not JSON objects, exceed 1MB, nest deeper than 6 levels, contain more than 4096 keys, or contain non-printable or oversized keys and string values. Only string, number and boolean values are accepted. The `unique_id` must be a valid SHA-256 hex digest. Per `unique_id`, at most one report per hour is accepted and at most 1000 reports are kept — the oldest are removed first, so a single sender can never fill up the disk.

### Running the webservice

Install dependencies:

```bash
pip install -r requirements.txt
```

**Development:**

```bash
python usage-report-collector.py
```

**Production (gunicorn):**

```bash
gunicorn wsgi:application
```

**Production (uWSGI):**

```bash
uwsgi --wsgi-file wsgi.py --callable application
```

**Production (Apache mod_wsgi):**

```apache
WSGIScriptAlias /report /path/to/reporter/wsgi.py
```

## Open source transparency

In the spirit of open source, the Apache CloudStack project publishes both the client-side code that generates reports (see `UsageReporter.java`) and this server-side collector. You can inspect exactly what data is sent and how it is stored.
18 changes: 18 additions & 0 deletions reporter/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

flask>=2.2,<4
179 changes: 179 additions & 0 deletions reporter/usage-report-collector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env python
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

from flask import abort, Flask, request
import json
import os
import re
import time

# A report of a few hundred KB would already be a very large environment
MAX_REPORT_SIZE = 1024 * 1024

# Reports are nested maps of counters; anything deeper than this is not a
# report generated by a Management Server
MAX_DEPTH = 6
MAX_KEYS = 4096
MAX_KEY_LENGTH = 128
MAX_STRING_LENGTH = 512

# The Management Server sends at most one report per day, so anything
# more frequent than this per unique ID is abuse
MIN_REPORT_INTERVAL = 3600

# Upper bound on the number of reports kept per unique ID; the oldest
# reports are removed first so a single ID can never fill up the disk
MAX_REPORTS_PER_ID = 1000

UNIQUE_ID_RE = re.compile('[0-9a-f]{64}')
REPORT_SUFFIX = '.json'


def json_response(response):
return json.dumps(response, indent=2) + "\n", 200, {'Content-Type': 'application/json; charset=utf-8'}


def validate_report(node, depth=1, counter=None):
"""Validate the structure of a report, returns an error string or None.

Only allows nested objects of printable string keys with string,
number or boolean values, with limits on depth, key count and
string lengths."""
if counter is None:
counter = {'keys': 0}

if depth > MAX_DEPTH:
return "Maximum nesting depth exceeded"

if isinstance(node, dict):
for key, value in node.items():
counter['keys'] += 1
if counter['keys'] > MAX_KEYS:
return "Too many keys in report"

if len(key) > MAX_KEY_LENGTH:
return "Key exceeds maximum length"

if not key.isprintable():
return "Key contains non-printable characters"

error = validate_report(value, depth + 1, counter)
if error is not None:
return error
elif isinstance(node, str):
if len(node) > MAX_STRING_LENGTH:
return "String value exceeds maximum length"

if not node.isprintable():
return "String value contains non-printable characters"
elif isinstance(node, bool) or isinstance(node, int) or isinstance(node, float):
pass
else:
return "Unsupported value type: %s" % type(node).__name__

return None


def generate_app(config=None):
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = MAX_REPORT_SIZE

base_dir = os.path.realpath(os.environ.get('REPORT_DIR', 'reports'))
os.makedirs(base_dir, mode=0o750, exist_ok=True)

@app.route('/report/<unique_id>', methods=['POST'])
def report(unique_id):
# The unique_id is always a SHA-256 hex digest generated by the
# Management Server. This also makes it safe to use as a directory
# name as it can not contain path separators or dots
if not UNIQUE_ID_RE.fullmatch(unique_id):
abort(400, "unique_id is not a valid SHA-256 hex digest")

# We expect JSON data, so if the Content-Type doesn't match JSON data we throw an error
if not request.is_json:
abort(417, "No or incorrect Content-Type header was supplied")

try:
payload = json.loads(request.data)
except json.JSONDecodeError:
abort(400, "Request body is not valid JSON")

if not isinstance(payload, dict) or not payload:
abort(400, "Request body is not a non-empty JSON object")

error = validate_report(payload)
if error is not None:
abort(400, error)

report_dir = os.path.join(base_dir, unique_id)
if os.path.commonpath([base_dir, os.path.realpath(report_dir)]) != base_dir:
abort(400, "Invalid unique_id")

os.makedirs(report_dir, mode=0o750, exist_ok=True)

existing = sorted(f for f in os.listdir(report_dir) if f.endswith(REPORT_SUFFIX))

# Rate limit per unique ID based on the newest stored report
if existing:
newest = os.path.getmtime(os.path.join(report_dir, existing[-1]))
if time.time() - newest < MIN_REPORT_INTERVAL:
abort(429, "A report for this unique_id was received recently")

# Bound the storage used per unique ID by removing the oldest reports
while len(existing) >= MAX_REPORTS_PER_ID:
os.remove(os.path.join(report_dir, existing.pop(0)))

timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

payload["unique_id"] = unique_id
payload["timestamp"] = timestamp

filename = time.strftime("%Y-%m-%dT%H-%M-%SZ", time.gmtime()) + REPORT_SUFFIX
report_path = os.path.join(report_dir, filename)

# Store the re-serialized, validated report and not the raw request
# body. Write to a temporary file first so readers of the directory
# never see partially written reports
tmp_path = report_path + '.tmp'
try:
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(payload, f, indent=2)
f.write("\n")
os.replace(tmp_path, report_path)
except OSError as e:
try:
os.remove(tmp_path)
except OSError:
pass
abort(500, "Failed to store report: %s" % str(e))

return json_response({})

return app


app = generate_app()

# Only run the App if this script is invoked from a Shell
if __name__ == '__main__':
app.debug = True
app.run(host='0.0.0.0', port=8088)

# Otherwise provide a variable called 'application' for mod_wsgi
else:
application = app
41 changes: 41 additions & 0 deletions reporter/wsgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

# WSGI entry point for mod_wsgi, gunicorn, uWSGI, etc.
# The main application file uses a hyphenated name which cannot be imported
# directly, so this shim loads it via importlib.
#
# mod_wsgi (Apache):
# WSGIScriptAlias /report /path/to/reporter/wsgi.py
#
# gunicorn:
# gunicorn wsgi:application
#
# uWSGI:
# uwsgi --wsgi-file wsgi.py --callable application

import importlib.util
import os

_spec = importlib.util.spec_from_file_location(
"usage_report_collector",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "usage-report-collector.py")
)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)

application = _mod.app
Loading
Loading