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
83 changes: 83 additions & 0 deletions .github/scripts/summarize-authz-performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Render structured authorization measurements, including failed BDD runs."""
import argparse
import json
from pathlib import Path

MARKER = "AUTHZ_PERFORMANCE "
NUMBERS = (
"seed", "concurrency", "resources", "wall_ns", "median_ns", "p95_ns",
"maximum_ns", "timeout_ns", "failures",
)


def read_results(text):
results, malformed = [], 0
for line in text.splitlines():
# Support both console logs and go test -json artifacts.
if line.startswith("{"):
try:
output = json.loads(line).get("Output")
if isinstance(output, str):
line = output
except (ValueError, AttributeError):
pass
if MARKER not in line:
continue
try:
result = json.loads(line.split(MARKER, 1)[1])
if not isinstance(result.get("case"), str) or not result["case"]:
raise ValueError("missing case")
if any(type(result.get(key)) is not int or result[key] < 0 for key in NUMBERS):
raise ValueError("invalid numeric field")
if not result["concurrency"] or not result["resources"] or not result["timeout_ns"]:
raise ValueError("invalid dimensions")
if result["failures"] > result["concurrency"]:
raise ValueError("invalid failure count")
results.append(result)
except (ValueError, TypeError, AttributeError):
malformed += 1
return sorted(results, key=lambda row: (row["concurrency"], row["case"], row["seed"])), malformed


def milliseconds(nanoseconds):
return f"{nanoseconds / 1_000_000:.2f} ms"


def render(text, outcome):
results, malformed = read_results(text)
lines = ["### Authorization v2 concurrency performance", "", f"BDD step outcome: **{outcome}**.", ""]
if results:
lines += [
"| Case | Concurrency | Resources | Seed | Wall | Median | p95 | Maximum | Timeout | Failures | Requests |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
]
for row in results:
case = row["case"].replace("|", "\\|").replace("\n", " ").replace("\r", " ")
status = "FAIL" if row["failures"] else "PASS"
cells = [case, str(row["concurrency"]), str(row["resources"]), str(row["seed"])]
cells += [milliseconds(row[key]) for key in ("wall_ns", "median_ns", "p95_ns", "maximum_ns", "timeout_ns")]
cells += [str(row["failures"]), status]
lines.append("| " + " | ".join(cells) + " |")
lines += ["", f"Reported {len(results)} completed case batches. Missing measurements are not passes."]
else:
lines.append("No authorization measurements were produced. Check BDD setup and logs; this is not a passing performance result.")
if malformed:
lines += ["", f"**Summary error: {malformed} malformed performance record(s).**"]
lines += ["", "Fixture setup is excluded. PASS means requests completed with the expected decisions. Failures include request errors, timeouts, and incorrect decisions. Latency is report-only until a baseline is established; the request timeout is not a latency target.", ""]
return "\n".join(lines), malformed


def main():
parser = argparse.ArgumentParser()
parser.add_argument("log", type=Path)
parser.add_argument("--bdd-outcome", default="unknown", choices=["success", "failure", "cancelled", "skipped", "unknown"])
args = parser.parse_args()
text = args.log.read_text() if args.log.exists() else ""
summary, malformed = render(text, args.bdd_outcome)
print(summary)
return bool(malformed)


if __name__ == "__main__":
raise SystemExit(main())
61 changes: 61 additions & 0 deletions .github/scripts/test_summarize_authz_performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import importlib.util
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import unittest

spec = importlib.util.spec_from_file_location("summary", Path(__file__).with_name("summarize-authz-performance.py"))
summary = importlib.util.module_from_spec(spec)
spec.loader.exec_module(summary)


class SummaryTests(unittest.TestCase):
def record(self, **changes):
row = dict(case="allowed_read", concurrency=50, resources=3, seed=4625,
wall_ns=150000000, median_ns=100000000, p95_ns=130000000,
maximum_ns=140000000, timeout_ns=30000000000, failures=0)
row.update(changes)
return summary.MARKER + json.dumps(row)

def test_console_and_json_records_have_identical_rendering(self):
record = self.record()
console, errors = summary.render(record, "success")
encoded, _ = summary.render(json.dumps({"Action": "output", "Output": record + "\n"}), "success")
self.assertEqual(console, encoded)
self.assertEqual(errors, 0)
self.assertIn("130.00 ms | 140.00 ms | 30000.00 ms | 0 | PASS", console)

def test_partial_failure_keeps_rows_without_gating_slow_requests(self):
text = self.record(case="denied_user", failures=2) + "\n" + self.record(case="allowed_read", maximum_ns=8000000000)
rendered, errors = summary.render(text, "failure")
self.assertEqual(errors, 0)
self.assertIn("BDD step outcome: **failure**", rendered)
self.assertIn("8000.00 ms | 30000.00 ms | 0 | PASS |", rendered)
self.assertIn("| 2 | FAIL |", rendered)
self.assertIn("Latency is report-only", rendered)
self.assertLess(rendered.index("allowed_read"), rendered.index("denied_user"))

def test_cli_handles_missing_log_after_setup_failure(self):
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run(
[sys.executable, str(Path(__file__).with_name("summarize-authz-performance.py")),
str(Path(directory) / "missing.log"), "--bdd-outcome", "failure"],
capture_output=True, text=True, check=True,
)
self.assertIn("No authorization measurements", result.stdout)
self.assertIn("BDD step outcome: **failure**", result.stdout)

def test_missing_and_malformed_measurements_are_not_passes(self):
rendered, errors = summary.render("setup failed", "failure")
self.assertIn("No authorization measurements", rendered)
self.assertEqual(errors, 0)
rendered, errors = summary.render(self.record() + "\n" + summary.MARKER + "{}", "failure")
self.assertEqual(errors, 1)
self.assertIn("malformed performance record", rendered)
self.assertIn("allowed_read", rendered)


if __name__ == "__main__":
unittest.main()
13 changes: 12 additions & 1 deletion .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,22 @@ jobs:
go-version-file: go.work
cache: false

- name: Test authorization summary renderer
run: python3 -m unittest discover -s .github/scripts -p 'test_summarize_authz_performance.py'

- name: Build local platform-cukes image for testing
run: docker build -t platform-cukes .

- name: Run BDD Tests
id: bdd
shell: bash
run: |
CUKES_LOG_HANDLER=console go test ./tests-bdd -v -timeout=20m --tags=cukes --godog.random --godog.format="cucumber:$(pwd)/cukes_platform_report.json,pretty:$(pwd)/cukes_platform_report.log,pretty" ./features 2>&1 | tee cukes_test_output.log

- name: Summarize authorization performance
if: ${{ !cancelled() }}
run: |
CUKES_LOG_HANDLER=console go test ./tests-bdd -v --tags=cukes --godog.random --godog.format="cucumber:$(pwd)/cukes_platform_report.json,pretty:$(pwd)/cukes_platform_report.log,pretty" ./features
python3 .github/scripts/summarize-authz-performance.py cukes_test_output.log --bdd-outcome "${{ steps.bdd.outcome || 'skipped' }}" >> "$GITHUB_STEP_SUMMARY"

- name: Check for undefined steps
run: |
Expand All @@ -557,6 +567,7 @@ jobs:
path: |
cukes_platform_report.json
cukes_platform_report.log
cukes_test_output.log
retention-days: 1

# test otdfctl CLI e2e against platform PR branch
Expand Down
42 changes: 42 additions & 0 deletions tests-bdd/cukes/scale_setup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cukes

import (
"context"
"errors"
"fmt"
"sync"

"github.com/opentdf/platform/protocol/go/policy"
)

// Setup uses bounded API calls and finishes before authorization is measured.
func createScaleMappings(ctx context.Context, count int, create func(context.Context, int) error) error {
// Match the default HTTP idle pool so thousands of setup calls reuse connections.
const batchSize = 2
for start := 0; start < count; start += batchSize {
end := min(start+batchSize, count)
failures := make([]error, end-start)
var workers sync.WaitGroup
for index := start; index < end; index++ {
workers.Go(func() { failures[index-start] = create(ctx, index) })
}
workers.Wait()
if err := errors.Join(failures...); err != nil {
return err
}
}
return nil
}

func scaleMappingInputs(ctx context.Context, count int, attributeRef, namespaceRef string) (*PlatformScenarioContext, *policy.Attribute, string, error) {
scenario := GetPlatformScenarioContext(ctx)
attribute, ok := scenario.GetObject(attributeRef).(*policy.Attribute)
if !ok || count <= 0 || count > len(attribute.GetValues()) {
return nil, nil, "", fmt.Errorf("attribute %q must contain at least %d values for mapping setup", attributeRef, count)
}
namespace, ok := scenario.GetObject(namespaceRef).(string)
if !ok {
return nil, nil, "", fmt.Errorf("missing namespace %q", namespaceRef)
}
return scenario, attribute, namespace, nil
}
110 changes: 100 additions & 10 deletions tests-bdd/cukes/steps_attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"

"github.com/cucumber/godog"
"github.com/opentdf/platform/protocol/go/policy"
Expand All @@ -19,6 +22,19 @@ type AttributesStepDefinitions struct {
PlatformCukesContext *PlatformTestSuiteContext
}

func parseAttributeRule(rule string) (policy.AttributeRuleTypeEnum, error) {
switch strings.TrimSpace(rule) {
case "anyOf":
return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, nil
case "allOf":
return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, nil
case "hierarchy":
return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, nil
default:
return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED, fmt.Errorf("unknown attribute rule type %s", rule)
}
}

func (s *AttributesStepDefinitions) aAttributeDef(ctx context.Context, _ string, _ string) (context.Context, error) {
return ctx, nil
}
Expand Down Expand Up @@ -98,16 +114,9 @@ func (s *AttributesStepDefinitions) iSendARequestToCreateAnAttributeWithGenerate
return ctx, fmt.Errorf("unable to get namespace id for %s", namespaceRef)
}

var ruleType policy.AttributeRuleTypeEnum
switch strings.TrimSpace(rule) {
case "anyOf":
ruleType = policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF
case "allOf":
ruleType = policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF
case "hierarchy":
ruleType = policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY
default:
return ctx, fmt.Errorf("unknown attribute rule type %s", rule)
ruleType, err := parseAttributeRule(rule)
if err != nil {
return ctx, err
}

values := make([]string, 0, valueCount)
Expand All @@ -128,11 +137,92 @@ func (s *AttributesStepDefinitions) iSendARequestToCreateAnAttributeWithGenerate
return ctx, nil
}

func (s *AttributesStepDefinitions) iSendARequestToCreateAnAttributeWithBatchedGeneratedValues(ctx context.Context, referenceID, namespaceRef, name, rule string, valueCount, batchSize int) (context.Context, error) {
scenarioContext := GetPlatformScenarioContext(ctx)
scenarioContext.ClearError()
if valueCount < 1 {
return ctx, errors.New("generated value count must be positive")
}
if batchSize < 1 {
return ctx, errors.New("generated value batch size must be positive")
}

namespaceID, ok := scenarioContext.GetObject(strings.TrimSpace(namespaceRef)).(string)
if !ok {
return ctx, fmt.Errorf("unable to get namespace id for %s", namespaceRef)
}
ruleType, err := parseAttributeRule(rule)
if err != nil {
return ctx, err
}

created, err := scenarioContext.SDK.Attributes.CreateAttribute(ctx, &attributes.CreateAttributeRequest{
NamespaceId: namespaceID,
Name: strings.TrimSpace(name),
Rule: ruleType,
})
if err != nil {
scenarioContext.SetError(err)
return ctx, nil
}
if created.GetAttribute() == nil {
return ctx, errors.New("create attribute returned no attribute")
}

started := time.Now()
values := make([]*policy.Value, valueCount)
for batchStart := 0; batchStart < valueCount; batchStart += batchSize {
batchEnd := min(batchStart+batchSize, valueCount)
batchCtx, cancel := context.WithCancel(ctx)
errCh := make(chan error, batchEnd-batchStart)
var wg sync.WaitGroup
for i := batchStart; i < batchEnd; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
resp, createErr := scenarioContext.SDK.Attributes.CreateAttributeValue(batchCtx, &attributes.CreateAttributeValueRequest{
AttributeId: created.GetAttribute().GetId(),
Value: fmt.Sprintf("v%04d", index),
})
if createErr != nil {
errCh <- fmt.Errorf("create generated attribute value v%04d: %w", index, createErr)
cancel()
return
}
if resp.GetValue() == nil {
errCh <- fmt.Errorf("create generated attribute value v%04d returned no value", index)
cancel()
return
}
values[index] = resp.GetValue()
}(i)
}
wg.Wait()
cancel()
close(errCh)
if batchErr, hasBatchErr := <-errCh; hasBatchErr {
scenarioContext.SetError(batchErr)
return ctx, nil
}
}

created.GetAttribute().Values = values
scenarioContext.RecordObject(strings.TrimSpace(referenceID), created.GetAttribute())
scenarioContext.TestSuiteContext.Logger.Info(
"created generated attribute values in batches",
slog.Int("value_count", valueCount),
slog.Int("batch_size", batchSize),
slog.Duration("duration", time.Since(started)),
)
return ctx, nil
}

func RegisterAttributeStepDefinitions(ctx *godog.ScenarioContext, x *PlatformTestSuiteContext) {
stepDefinitions := AttributesStepDefinitions{
PlatformCukesContext: x,
}
ctx.Step(`^a (anyOf|allOf|hierarchy) attribute definition with values: "([^"]*)"$`, stepDefinitions.aAttributeDef)
ctx.Step(`^I send a request to create an attribute with:$`, stepDefinitions.iSendARequestToCreateAnAttributeWith)
ctx.Step(`^I send a request to create an attribute referenced as "([^"]*)" in namespace "([^"]*)" named "([^"]*)" with rule "([^"]*)" and (\d+) generated values$`, stepDefinitions.iSendARequestToCreateAnAttributeWithGeneratedValues)
ctx.Step(`^I send a request to create an attribute referenced as "([^"]*)" in namespace "([^"]*)" named "([^"]*)" with rule "([^"]*)" and (\d+) generated values in batches of (\d+)$`, stepDefinitions.iSendARequestToCreateAnAttributeWithBatchedGeneratedValues)
}
1 change: 1 addition & 0 deletions tests-bdd/cukes/steps_authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ func (s *AuthorizationServiceStepDefinitions) theDecisionResponseForResourceShou
}

func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) {
ctx.Step(`^I exercise the authorization cases with (\d+) concurrent requests each, seed (\d+), and request timeout "([^"]*)" for attribute "([^"]*)":$`, exerciseAuthorizationCases)
stepDefinitions := AuthorizationServiceStepDefinitions{}
ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs)
ctx.Step(`^there is a claims subject entity referenced as "([^"]*)" with claims:$`, stepDefinitions.thereIsAClaimsSubjectEntityReferencedAsWithClaims)
Expand Down
Loading
Loading