red-roving-rascal 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- red_roving_rascal-0.1.0/.github/workflows/publish.yml +37 -0
- red_roving_rascal-0.1.0/.gitignore +19 -0
- red_roving_rascal-0.1.0/Dockerfile +10 -0
- red_roving_rascal-0.1.0/LICENSE +13 -0
- red_roving_rascal-0.1.0/PKG-INFO +37 -0
- red_roving_rascal-0.1.0/README.md +19 -0
- red_roving_rascal-0.1.0/cdk/.npmignore +6 -0
- red_roving_rascal-0.1.0/cdk/cdk.json +6 -0
- red_roving_rascal-0.1.0/cdk/package.json +31 -0
- red_roving_rascal-0.1.0/cdk/src/app.ts +14 -0
- red_roving_rascal-0.1.0/cdk/src/rascal-construct.ts +263 -0
- red_roving_rascal-0.1.0/cdk/src/rascal-stack.ts +26 -0
- red_roving_rascal-0.1.0/cdk/tsconfig.json +14 -0
- red_roving_rascal-0.1.0/clients/go/README.md +12 -0
- red_roving_rascal-0.1.0/clients/java/README.md +13 -0
- red_roving_rascal-0.1.0/pyproject.toml +31 -0
- red_roving_rascal-0.1.0/src/rascal/__init__.py +3 -0
- red_roving_rascal-0.1.0/src/rascal/app.py +142 -0
- red_roving_rascal-0.1.0/src/rascal/auth.py +91 -0
- red_roving_rascal-0.1.0/src/rascal/client.py +94 -0
- red_roving_rascal-0.1.0/src/rascal/models.py +46 -0
- red_roving_rascal-0.1.0/src/rascal/registry.py +94 -0
- red_roving_rascal-0.1.0/src/rascal/server.py +19 -0
- red_roving_rascal-0.1.0/src/rascal/storage.py +59 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish-pypi:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: pip install build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
21
|
+
|
|
22
|
+
publish-npm:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: actions/setup-node@v4
|
|
27
|
+
with:
|
|
28
|
+
node-version: "20"
|
|
29
|
+
registry-url: "https://registry.npmjs.org"
|
|
30
|
+
- working-directory: cdk
|
|
31
|
+
run: npm install
|
|
32
|
+
- working-directory: cdk
|
|
33
|
+
run: npm run build
|
|
34
|
+
- working-directory: cdk
|
|
35
|
+
run: npm publish --access public
|
|
36
|
+
env:
|
|
37
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Apache License 2.0
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: red-roving-rascal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight API testing and evaluation toolkit
|
|
5
|
+
Project-URL: Homepage, https://github.com/dboyd13/red-roving-rascal
|
|
6
|
+
Project-URL: Repository, https://github.com/dboyd13/red-roving-rascal
|
|
7
|
+
Author: dboyd13
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: boto3
|
|
15
|
+
Requires-Dist: httpx
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# red-roving-rascal
|
|
20
|
+
|
|
21
|
+
A lightweight API testing and evaluation toolkit.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install red-roving-rascal
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from rascal.client import RascalClient
|
|
33
|
+
|
|
34
|
+
client = RascalClient(endpoint="https://your-api.example.com")
|
|
35
|
+
result = client.run_job(inputs=["hello world"], target="my-service")
|
|
36
|
+
print(result)
|
|
37
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# red-roving-rascal
|
|
2
|
+
|
|
3
|
+
A lightweight API testing and evaluation toolkit.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install red-roving-rascal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from rascal.client import RascalClient
|
|
15
|
+
|
|
16
|
+
client = RascalClient(endpoint="https://your-api.example.com")
|
|
17
|
+
result = client.run_job(inputs=["hello world"], target="my-service")
|
|
18
|
+
print(result)
|
|
19
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rascal-cdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CDK construct for deploying a rascal backend (API GW + ECS Fargate + DynamoDB)",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"main": "dist/src/rascal-construct.js",
|
|
7
|
+
"types": "dist/src/rascal-construct.d.ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/dboyd13/red-roving-rascal.git",
|
|
11
|
+
"directory": "cdk"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "jest",
|
|
16
|
+
"cdk": "cdk"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"aws-cdk-lib": "^2.150.0",
|
|
20
|
+
"constructs": "^10.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"aws-cdk-lib": "^2.150.0",
|
|
24
|
+
"constructs": "^10.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"aws-cdk": "^2.150.0",
|
|
28
|
+
"@types/node": "*",
|
|
29
|
+
"typescript": "^5.4.5"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { App } from 'aws-cdk-lib';
|
|
3
|
+
import { RascalStack } from './rascal-stack';
|
|
4
|
+
|
|
5
|
+
const app = new App();
|
|
6
|
+
|
|
7
|
+
const allowedAccounts = (app.node.tryGetContext('allowedAccounts') ?? '')
|
|
8
|
+
.split(',')
|
|
9
|
+
.filter((s: string) => s.length > 0);
|
|
10
|
+
|
|
11
|
+
new RascalStack(app, 'RascalStack', {
|
|
12
|
+
allowedAccountIds: allowedAccounts,
|
|
13
|
+
principalOrgId: app.node.tryGetContext('principalOrgId'),
|
|
14
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Construct } from 'constructs';
|
|
2
|
+
import {
|
|
3
|
+
aws_ec2 as ec2,
|
|
4
|
+
aws_ecs as ecs,
|
|
5
|
+
aws_iam as iam,
|
|
6
|
+
aws_dynamodb as dynamodb,
|
|
7
|
+
aws_elasticloadbalancingv2 as elbv2,
|
|
8
|
+
aws_apigateway as apigw,
|
|
9
|
+
aws_logs as logs,
|
|
10
|
+
aws_cloudwatch as cw,
|
|
11
|
+
CfnOutput,
|
|
12
|
+
Duration,
|
|
13
|
+
RemovalPolicy,
|
|
14
|
+
} from 'aws-cdk-lib';
|
|
15
|
+
|
|
16
|
+
export interface RascalBackendProps {
|
|
17
|
+
readonly vpc?: ec2.IVpc;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* AWS account IDs allowed to call the API.
|
|
21
|
+
* Deny-by-default: an empty list means no callers are permitted.
|
|
22
|
+
*/
|
|
23
|
+
readonly allowedAccountIds?: string[];
|
|
24
|
+
|
|
25
|
+
/** Optional org ID for org-level restriction. */
|
|
26
|
+
readonly principalOrgId?: string;
|
|
27
|
+
|
|
28
|
+
readonly containerImage?: ecs.ContainerImage;
|
|
29
|
+
readonly taskCpu?: number;
|
|
30
|
+
readonly taskMemoryMiB?: number;
|
|
31
|
+
readonly desiredCount?: number;
|
|
32
|
+
readonly containerPort?: number;
|
|
33
|
+
readonly removalPolicy?: RemovalPolicy;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reusable CDK construct: API Gateway (IAM auth + deny-by-default) →
|
|
38
|
+
* VPC Link → NLB → ECS Fargate → DynamoDB.
|
|
39
|
+
*
|
|
40
|
+
* Uses only aws-cdk-lib + constructs — no vendor-specific dependencies.
|
|
41
|
+
*/
|
|
42
|
+
export class RascalBackendConstruct extends Construct {
|
|
43
|
+
public readonly apiEndpoint: string;
|
|
44
|
+
public readonly taskRole: iam.IRole;
|
|
45
|
+
public readonly dataTable: dynamodb.ITable;
|
|
46
|
+
public readonly jobsTable: dynamodb.ITable;
|
|
47
|
+
|
|
48
|
+
constructor(scope: Construct, id: string, props: RascalBackendProps = {}) {
|
|
49
|
+
super(scope, id);
|
|
50
|
+
|
|
51
|
+
const containerPort = props.containerPort ?? 8080;
|
|
52
|
+
const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY;
|
|
53
|
+
|
|
54
|
+
// ── VPC ──────────────────────────────────────────────────────────
|
|
55
|
+
const vpc = props.vpc ?? new ec2.Vpc(this, 'Vpc', {
|
|
56
|
+
maxAzs: 2,
|
|
57
|
+
natGateways: 1,
|
|
58
|
+
subnetConfiguration: [
|
|
59
|
+
{ name: 'Public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
|
|
60
|
+
{ name: 'Private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── DynamoDB ─────────────────────────────────────────────────────
|
|
65
|
+
this.dataTable = new dynamodb.Table(this, 'DataTable', {
|
|
66
|
+
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
|
67
|
+
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
|
|
68
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
69
|
+
removalPolicy,
|
|
70
|
+
pointInTimeRecovery: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.jobsTable = new dynamodb.Table(this, 'JobsTable', {
|
|
74
|
+
partitionKey: { name: 'jobId', type: dynamodb.AttributeType.STRING },
|
|
75
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
76
|
+
removalPolicy,
|
|
77
|
+
timeToLiveAttribute: 'ttl',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── ECS ──────────────────────────────────────────────────────────
|
|
81
|
+
const cluster = new ecs.Cluster(this, 'Cluster', { vpc });
|
|
82
|
+
|
|
83
|
+
const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', {
|
|
84
|
+
cpu: props.taskCpu ?? 1024,
|
|
85
|
+
memoryLimitMiB: props.taskMemoryMiB ?? 2048,
|
|
86
|
+
});
|
|
87
|
+
this.taskRole = taskDef.taskRole;
|
|
88
|
+
|
|
89
|
+
taskDef.executionRole?.addManagedPolicy(
|
|
90
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const logGroup = new logs.LogGroup(this, 'Logs', {
|
|
94
|
+
retention: logs.RetentionDays.ONE_MONTH,
|
|
95
|
+
removalPolicy,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
taskDef.addContainer('App', {
|
|
99
|
+
image: props.containerImage ?? ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
|
|
100
|
+
portMappings: [{ containerPort }],
|
|
101
|
+
environment: {
|
|
102
|
+
DATA_TABLE: this.dataTable.tableName,
|
|
103
|
+
JOBS_TABLE: this.jobsTable.tableName,
|
|
104
|
+
PORT: containerPort.toString(),
|
|
105
|
+
},
|
|
106
|
+
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'rascal', logGroup }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
this.dataTable.grantReadWriteData(taskDef.taskRole);
|
|
110
|
+
this.jobsTable.grantReadWriteData(taskDef.taskRole);
|
|
111
|
+
|
|
112
|
+
const sg = new ec2.SecurityGroup(this, 'Sg', {
|
|
113
|
+
vpc,
|
|
114
|
+
description: 'Backend ECS service',
|
|
115
|
+
allowAllOutbound: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const service = new ecs.FargateService(this, 'Service', {
|
|
119
|
+
cluster,
|
|
120
|
+
taskDefinition: taskDef,
|
|
121
|
+
desiredCount: props.desiredCount ?? 1,
|
|
122
|
+
assignPublicIp: false,
|
|
123
|
+
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
124
|
+
securityGroups: [sg],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── NLB ──────────────────────────────────────────────────────────
|
|
128
|
+
const nlb = new elbv2.NetworkLoadBalancer(this, 'Nlb', {
|
|
129
|
+
vpc,
|
|
130
|
+
internetFacing: false,
|
|
131
|
+
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
nlb.addListener('Listener', { port: 80, protocol: elbv2.Protocol.TCP })
|
|
135
|
+
.addTargets('Targets', {
|
|
136
|
+
port: containerPort,
|
|
137
|
+
targets: [service],
|
|
138
|
+
healthCheck: { protocol: elbv2.Protocol.TCP, interval: Duration.seconds(30) },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
sg.addIngressRule(
|
|
142
|
+
ec2.Peer.ipv4(vpc.vpcCidrBlock),
|
|
143
|
+
ec2.Port.tcp(containerPort),
|
|
144
|
+
'NLB health checks and traffic',
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// ── VPC Link ────────────────────────────────────────────────────
|
|
148
|
+
const vpcLink = new apigw.VpcLink(this, 'VpcLink', { targets: [nlb] });
|
|
149
|
+
|
|
150
|
+
// ── API Gateway (IAM auth + deny-by-default resource policy) ────
|
|
151
|
+
const api = new apigw.RestApi(this, 'Api', {
|
|
152
|
+
restApiName: 'RascalApi',
|
|
153
|
+
description: 'Backend API with IAM auth and deny-by-default resource policy',
|
|
154
|
+
policy: this.buildResourcePolicy(props),
|
|
155
|
+
deployOptions: {
|
|
156
|
+
stageName: 'v1',
|
|
157
|
+
metricsEnabled: true,
|
|
158
|
+
throttlingRateLimit: 100,
|
|
159
|
+
throttlingBurstLimit: 50,
|
|
160
|
+
},
|
|
161
|
+
defaultMethodOptions: {
|
|
162
|
+
authorizationType: apigw.AuthorizationType.IAM,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const nlbIntegration = (method: string, path: string) => new apigw.Integration({
|
|
167
|
+
type: apigw.IntegrationType.HTTP_PROXY,
|
|
168
|
+
integrationHttpMethod: method,
|
|
169
|
+
uri: `http://${nlb.loadBalancerDnsName}${path}`,
|
|
170
|
+
options: { connectionType: apigw.ConnectionType.VPC_LINK, vpcLink },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// POST /jobs
|
|
174
|
+
api.root.addResource('jobs').addMethod('POST', nlbIntegration('POST', '/jobs'));
|
|
175
|
+
|
|
176
|
+
// GET /jobs/{job_id}
|
|
177
|
+
const jobId = api.root.getResource('jobs')!.addResource('{job_id}');
|
|
178
|
+
jobId.addMethod('GET', new apigw.Integration({
|
|
179
|
+
type: apigw.IntegrationType.HTTP_PROXY,
|
|
180
|
+
integrationHttpMethod: 'GET',
|
|
181
|
+
uri: `http://${nlb.loadBalancerDnsName}/jobs/{job_id}`,
|
|
182
|
+
options: {
|
|
183
|
+
connectionType: apigw.ConnectionType.VPC_LINK,
|
|
184
|
+
vpcLink,
|
|
185
|
+
requestParameters: { 'integration.request.path.job_id': 'method.request.path.job_id' },
|
|
186
|
+
},
|
|
187
|
+
}), { requestParameters: { 'method.request.path.job_id': true } });
|
|
188
|
+
|
|
189
|
+
// GET /health (no auth — for NLB probes)
|
|
190
|
+
api.root.addResource('health').addMethod('GET', nlbIntegration('GET', '/health'), {
|
|
191
|
+
authorizationType: apigw.AuthorizationType.NONE,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
this.apiEndpoint = api.url;
|
|
195
|
+
|
|
196
|
+
// ── CloudWatch Alarms ───────────────────────────────────────────
|
|
197
|
+
new cw.Alarm(this, 'Api5xxAlarm', {
|
|
198
|
+
metric: api.metricServerError({ period: Duration.minutes(5) }),
|
|
199
|
+
threshold: 5,
|
|
200
|
+
evaluationPeriods: 2,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
new cw.Alarm(this, 'EcsCpuAlarm', {
|
|
204
|
+
metric: service.metricCpuUtilization({ period: Duration.minutes(5) }),
|
|
205
|
+
threshold: 90,
|
|
206
|
+
evaluationPeriods: 3,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── Outputs ─────────────────────────────────────────────────────
|
|
210
|
+
new CfnOutput(this, 'ApiEndpoint', { value: api.url });
|
|
211
|
+
new CfnOutput(this, 'TaskRoleArn', { value: taskDef.taskRole.roleArn });
|
|
212
|
+
new CfnOutput(this, 'DataTableName', { value: this.dataTable.tableName });
|
|
213
|
+
new CfnOutput(this, 'JobsTableName', { value: this.jobsTable.tableName });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Deny-by-default API Gateway resource policy.
|
|
218
|
+
* If no account IDs are provided, the policy denies all principals.
|
|
219
|
+
*/
|
|
220
|
+
private buildResourcePolicy(props: RascalBackendProps): iam.PolicyDocument {
|
|
221
|
+
const statements: iam.PolicyStatement[] = [];
|
|
222
|
+
const allowedAccounts = props.allowedAccountIds ?? [];
|
|
223
|
+
const hasAccounts = allowedAccounts.length > 0;
|
|
224
|
+
const hasOrgId = !!props.principalOrgId;
|
|
225
|
+
|
|
226
|
+
if (hasAccounts || hasOrgId) {
|
|
227
|
+
const allow = new iam.PolicyStatement({
|
|
228
|
+
effect: iam.Effect.ALLOW,
|
|
229
|
+
principals: [new iam.AnyPrincipal()],
|
|
230
|
+
actions: ['execute-api:Invoke'],
|
|
231
|
+
resources: ['execute-api:/*'],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const conditions: Record<string, string | string[]> = {};
|
|
235
|
+
if (hasAccounts) conditions['aws:PrincipalAccount'] = allowedAccounts;
|
|
236
|
+
if (hasOrgId) conditions['aws:PrincipalOrgID'] = props.principalOrgId!;
|
|
237
|
+
allow.addCondition('StringEquals', conditions);
|
|
238
|
+
statements.push(allow);
|
|
239
|
+
|
|
240
|
+
const deny = new iam.PolicyStatement({
|
|
241
|
+
effect: iam.Effect.DENY,
|
|
242
|
+
principals: [new iam.AnyPrincipal()],
|
|
243
|
+
actions: ['execute-api:Invoke'],
|
|
244
|
+
resources: ['execute-api:/*'],
|
|
245
|
+
});
|
|
246
|
+
const denyConditions: Record<string, string | string[]> = {};
|
|
247
|
+
if (hasAccounts) denyConditions['aws:PrincipalAccount'] = allowedAccounts;
|
|
248
|
+
if (hasOrgId) denyConditions['aws:PrincipalOrgID'] = props.principalOrgId!;
|
|
249
|
+
deny.addCondition('StringNotEquals', denyConditions);
|
|
250
|
+
statements.push(deny);
|
|
251
|
+
} else {
|
|
252
|
+
// No accounts configured — deny everyone
|
|
253
|
+
statements.push(new iam.PolicyStatement({
|
|
254
|
+
effect: iam.Effect.DENY,
|
|
255
|
+
principals: [new iam.AnyPrincipal()],
|
|
256
|
+
actions: ['execute-api:Invoke'],
|
|
257
|
+
resources: ['execute-api:/*'],
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return new iam.PolicyDocument({ statements });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Construct } from 'constructs';
|
|
2
|
+
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
|
|
3
|
+
import * as ecs from 'aws-cdk-lib/aws-ecs';
|
|
4
|
+
import { RascalBackendConstruct } from './rascal-construct';
|
|
5
|
+
|
|
6
|
+
export interface RascalStackProps extends StackProps {
|
|
7
|
+
/** Account IDs allowed to call the API. Empty = deny all. */
|
|
8
|
+
readonly allowedAccountIds?: string[];
|
|
9
|
+
readonly principalOrgId?: string;
|
|
10
|
+
readonly containerImage?: ecs.ContainerImage;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RascalStack extends Stack {
|
|
14
|
+
public readonly backend: RascalBackendConstruct;
|
|
15
|
+
|
|
16
|
+
constructor(scope: Construct, id: string, props: RascalStackProps = {}) {
|
|
17
|
+
super(scope, id, props);
|
|
18
|
+
|
|
19
|
+
this.backend = new RascalBackendConstruct(this, 'Backend', {
|
|
20
|
+
allowedAccountIds: props.allowedAccountIds,
|
|
21
|
+
principalOrgId: props.principalOrgId,
|
|
22
|
+
containerImage: props.containerImage,
|
|
23
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "node16",
|
|
5
|
+
"moduleResolution": "node16",
|
|
6
|
+
"lib": ["es2022"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"inlineSourceMap": true
|
|
12
|
+
},
|
|
13
|
+
"exclude": ["node_modules", "dist", "cdk.out"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# rascal-client (Java)
|
|
2
|
+
|
|
3
|
+
Java client for the rascal API. Publishes to Maven Central.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Placeholder — not yet implemented.
|
|
8
|
+
|
|
9
|
+
## Planned
|
|
10
|
+
|
|
11
|
+
- SigV4 request signing
|
|
12
|
+
- Async HTTP client
|
|
13
|
+
- Published as `com.github.dboyd13:rascal-client`
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "red-roving-rascal"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A lightweight API testing and evaluation toolkit"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "dboyd13" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"pydantic>=2.0",
|
|
22
|
+
"httpx",
|
|
23
|
+
"boto3",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/dboyd13/red-roving-rascal"
|
|
28
|
+
Repository = "https://github.com/dboyd13/red-roving-rascal"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/rascal"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""HTTP application with routing, job processing, and plugin support."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from http.server import BaseHTTPRequestHandler
|
|
8
|
+
|
|
9
|
+
from rascal.models import JobRequest, JobResponse, Summary, TestResult
|
|
10
|
+
from rascal.registry import Registry, Checker, Processor, DataSource, Reporter, Scorer
|
|
11
|
+
from rascal.storage import Storage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Default implementations ──────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
class _StubProcessor:
|
|
17
|
+
def process(self, input_text: str, target: str) -> str:
|
|
18
|
+
return f"processed: {input_text}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _StubChecker:
|
|
22
|
+
def check(self, input_text: str, output_text: str) -> int:
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _StubDataSource:
|
|
27
|
+
def load(self, tags: list[str] | None = None) -> list[str]:
|
|
28
|
+
return ["sample input 1", "sample input 2"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _JsonReporter:
|
|
32
|
+
def report(self, summary: object) -> str:
|
|
33
|
+
if hasattr(summary, "model_dump_json"):
|
|
34
|
+
return summary.model_dump_json(indent=2) # type: ignore
|
|
35
|
+
return json.dumps(summary, indent=2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _DefaultScorer:
|
|
39
|
+
def score(self, results: list, threshold: float) -> Summary:
|
|
40
|
+
total = len(results)
|
|
41
|
+
pass_count = sum(1 for r in results if r.score <= 3)
|
|
42
|
+
pass_rate = pass_count / total if total else 0.0
|
|
43
|
+
return Summary(
|
|
44
|
+
total=total,
|
|
45
|
+
pass_count=pass_count,
|
|
46
|
+
fail_count=total - pass_count,
|
|
47
|
+
pass_rate=pass_rate,
|
|
48
|
+
threshold=threshold,
|
|
49
|
+
passed=pass_rate >= threshold,
|
|
50
|
+
results=results,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Register defaults
|
|
55
|
+
Registry.register_default("processor", _StubProcessor())
|
|
56
|
+
Registry.register_default("checker", _StubChecker())
|
|
57
|
+
Registry.register_default("data_source", _StubDataSource())
|
|
58
|
+
Registry.register_default("reporter", _JsonReporter())
|
|
59
|
+
Registry.register_default("scorer", _DefaultScorer())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Job processing ───────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def _process_job(request: JobRequest) -> JobResponse:
|
|
65
|
+
"""Run inputs through processor and checker, return results."""
|
|
66
|
+
processor: Processor = Registry.get("processor") # type: ignore
|
|
67
|
+
checker: Checker = Registry.get("checker") # type: ignore
|
|
68
|
+
|
|
69
|
+
# Use provided inputs, or load from data source if empty
|
|
70
|
+
inputs = request.inputs
|
|
71
|
+
if not inputs and Registry.has("data_source"):
|
|
72
|
+
source: DataSource = Registry.get("data_source") # type: ignore
|
|
73
|
+
inputs = source.load(request.tags or None)
|
|
74
|
+
|
|
75
|
+
results: list[TestResult] = []
|
|
76
|
+
for inp in inputs:
|
|
77
|
+
output = processor.process(inp, request.target)
|
|
78
|
+
score = checker.check(inp, output)
|
|
79
|
+
results.append(TestResult(
|
|
80
|
+
input_text=inp,
|
|
81
|
+
output_text=output,
|
|
82
|
+
score=score,
|
|
83
|
+
checker=type(checker).__name__,
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
scorer: Scorer = Registry.get("scorer") # type: ignore
|
|
87
|
+
summary = scorer.score(results, request.threshold)
|
|
88
|
+
|
|
89
|
+
job = JobResponse(
|
|
90
|
+
job_id=str(uuid.uuid4()),
|
|
91
|
+
status="complete",
|
|
92
|
+
summary=summary,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Persist if storage is available
|
|
96
|
+
try:
|
|
97
|
+
storage = Storage()
|
|
98
|
+
storage.save_job(job)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
return job
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── HTTP handler ─────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
class AppHandler(BaseHTTPRequestHandler):
|
|
108
|
+
|
|
109
|
+
def do_GET(self):
|
|
110
|
+
if self.path == "/health":
|
|
111
|
+
self._json(200, {"status": "ok", "plugins": Registry.keys()})
|
|
112
|
+
elif self.path.startswith("/jobs/"):
|
|
113
|
+
job_id = self.path.rsplit("/", 1)[-1]
|
|
114
|
+
try:
|
|
115
|
+
storage = Storage()
|
|
116
|
+
job = storage.get_job(job_id)
|
|
117
|
+
if job:
|
|
118
|
+
self._json(200, json.loads(job.model_dump_json()))
|
|
119
|
+
else:
|
|
120
|
+
self._json(404, {"error": "job not found"})
|
|
121
|
+
except Exception:
|
|
122
|
+
self._json(404, {"error": "job not found"})
|
|
123
|
+
else:
|
|
124
|
+
self._json(404, {"error": "not found"})
|
|
125
|
+
|
|
126
|
+
def do_POST(self):
|
|
127
|
+
if self.path == "/jobs":
|
|
128
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
129
|
+
body = self.rfile.read(length)
|
|
130
|
+
request = JobRequest.model_validate_json(body)
|
|
131
|
+
job = _process_job(request)
|
|
132
|
+
self._json(200, json.loads(job.model_dump_json()))
|
|
133
|
+
else:
|
|
134
|
+
self._json(404, {"error": "not found"})
|
|
135
|
+
|
|
136
|
+
def _json(self, status: int, data: dict):
|
|
137
|
+
body = json.dumps(data).encode()
|
|
138
|
+
self.send_response(status)
|
|
139
|
+
self.send_header("Content-Type", "application/json")
|
|
140
|
+
self.send_header("Content-Length", str(len(body)))
|
|
141
|
+
self.end_headers()
|
|
142
|
+
self.wfile.write(body)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""SigV4 request signing for API Gateway IAM auth."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import datetime
|
|
7
|
+
import os
|
|
8
|
+
from urllib.parse import urlparse, quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sign(key: bytes, msg: str) -> bytes:
|
|
14
|
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_signature_key(key: str, date_stamp: str, region: str, service: str) -> bytes:
|
|
18
|
+
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
|
19
|
+
k_region = _sign(k_date, region)
|
|
20
|
+
k_service = _sign(k_region, service)
|
|
21
|
+
return _sign(k_service, "aws4_request")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sigv4_headers(
|
|
25
|
+
method: str,
|
|
26
|
+
url: str,
|
|
27
|
+
body: bytes = b"",
|
|
28
|
+
region: str | None = None,
|
|
29
|
+
service: str = "execute-api",
|
|
30
|
+
access_key: str | None = None,
|
|
31
|
+
secret_key: str | None = None,
|
|
32
|
+
session_token: str | None = None,
|
|
33
|
+
) -> dict[str, str]:
|
|
34
|
+
"""Generate SigV4 authorization headers for a request."""
|
|
35
|
+
access_key = access_key or os.environ.get("AWS_ACCESS_KEY_ID", "")
|
|
36
|
+
secret_key = secret_key or os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
|
37
|
+
session_token = session_token or os.environ.get("AWS_SESSION_TOKEN")
|
|
38
|
+
region = region or os.environ.get("AWS_REGION", "us-east-1")
|
|
39
|
+
|
|
40
|
+
parsed = urlparse(url)
|
|
41
|
+
host = parsed.hostname or ""
|
|
42
|
+
path = quote(parsed.path or "/", safe="/")
|
|
43
|
+
|
|
44
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
45
|
+
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
|
46
|
+
date_stamp = now.strftime("%Y%m%d")
|
|
47
|
+
|
|
48
|
+
canonical_querystring = parsed.query
|
|
49
|
+
payload_hash = hashlib.sha256(body).hexdigest()
|
|
50
|
+
|
|
51
|
+
headers_to_sign = {"host": host, "x-amz-date": amz_date}
|
|
52
|
+
if session_token:
|
|
53
|
+
headers_to_sign["x-amz-security-token"] = session_token
|
|
54
|
+
|
|
55
|
+
signed_header_keys = sorted(headers_to_sign.keys())
|
|
56
|
+
signed_headers = ";".join(signed_header_keys)
|
|
57
|
+
canonical_headers = "".join(f"{k}:{headers_to_sign[k]}\n" for k in signed_header_keys)
|
|
58
|
+
|
|
59
|
+
canonical_request = "\n".join([
|
|
60
|
+
method.upper(),
|
|
61
|
+
path,
|
|
62
|
+
canonical_querystring,
|
|
63
|
+
canonical_headers,
|
|
64
|
+
signed_headers,
|
|
65
|
+
payload_hash,
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
|
69
|
+
string_to_sign = "\n".join([
|
|
70
|
+
"AWS4-HMAC-SHA256",
|
|
71
|
+
amz_date,
|
|
72
|
+
credential_scope,
|
|
73
|
+
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
|
77
|
+
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
78
|
+
|
|
79
|
+
authorization = (
|
|
80
|
+
f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, "
|
|
81
|
+
f"SignedHeaders={signed_headers}, Signature={signature}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result = {
|
|
85
|
+
"Authorization": authorization,
|
|
86
|
+
"x-amz-date": amz_date,
|
|
87
|
+
"x-amz-content-sha256": payload_hash,
|
|
88
|
+
}
|
|
89
|
+
if session_token:
|
|
90
|
+
result["x-amz-security-token"] = session_token
|
|
91
|
+
return result
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Client for the rascal API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from rascal.models import JobRequest, JobResponse
|
|
9
|
+
from rascal.auth import sigv4_headers
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Type alias for auth providers: (method, url, body) -> headers dict
|
|
13
|
+
AuthSigner = Callable[[str, str, bytes], dict[str, str]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sigv4_auth(region: str | None = None) -> AuthSigner:
|
|
17
|
+
"""Create a SigV4 auth signer using environment credentials."""
|
|
18
|
+
def signer(method: str, url: str, body: bytes = b"") -> dict[str, str]:
|
|
19
|
+
return sigv4_headers(method, url, body, region=region)
|
|
20
|
+
return signer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RascalClient:
|
|
24
|
+
"""Client for interacting with a rascal backend.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
endpoint: Base URL of the API.
|
|
28
|
+
auth: Optional auth signer. Use sigv4_auth() for SigV4,
|
|
29
|
+
or provide a custom callable for other auth schemes.
|
|
30
|
+
timeout: Request timeout in seconds.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
# No auth (local dev)
|
|
34
|
+
client = RascalClient("http://localhost:8080")
|
|
35
|
+
|
|
36
|
+
# SigV4 auth
|
|
37
|
+
client = RascalClient("https://api.example.com/v1", auth=sigv4_auth("us-west-2"))
|
|
38
|
+
|
|
39
|
+
# Custom auth (e.g. CloudAuth)
|
|
40
|
+
client = RascalClient("https://api.example.com/v1", auth=my_cloud_auth_signer)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
endpoint: str,
|
|
46
|
+
auth: AuthSigner | None = None,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
):
|
|
49
|
+
self.endpoint = endpoint.rstrip("/")
|
|
50
|
+
self.auth = auth
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
|
|
53
|
+
def _headers(self, method: str, url: str, body: bytes = b"") -> dict[str, str]:
|
|
54
|
+
headers = {"Content-Type": "application/json"}
|
|
55
|
+
if self.auth:
|
|
56
|
+
headers.update(self.auth(method, url, body))
|
|
57
|
+
return headers
|
|
58
|
+
|
|
59
|
+
def run_job(
|
|
60
|
+
self,
|
|
61
|
+
inputs: list[str],
|
|
62
|
+
target: str,
|
|
63
|
+
threshold: float = 0.8,
|
|
64
|
+
tags: list[str] | None = None,
|
|
65
|
+
) -> JobResponse:
|
|
66
|
+
"""Submit inputs for processing."""
|
|
67
|
+
request = JobRequest(
|
|
68
|
+
inputs=inputs,
|
|
69
|
+
target=target,
|
|
70
|
+
threshold=threshold,
|
|
71
|
+
tags=tags or [],
|
|
72
|
+
)
|
|
73
|
+
url = f"{self.endpoint}/jobs"
|
|
74
|
+
body = request.model_dump_json().encode()
|
|
75
|
+
with httpx.Client(timeout=self.timeout) as http:
|
|
76
|
+
resp = http.post(url, content=body, headers=self._headers("POST", url, body))
|
|
77
|
+
resp.raise_for_status()
|
|
78
|
+
return JobResponse.model_validate_json(resp.content)
|
|
79
|
+
|
|
80
|
+
def get_job(self, job_id: str) -> JobResponse:
|
|
81
|
+
"""Poll for job results."""
|
|
82
|
+
url = f"{self.endpoint}/jobs/{job_id}"
|
|
83
|
+
with httpx.Client(timeout=self.timeout) as http:
|
|
84
|
+
resp = http.get(url, headers=self._headers("GET", url))
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
return JobResponse.model_validate_json(resp.content)
|
|
87
|
+
|
|
88
|
+
def health(self) -> dict:
|
|
89
|
+
"""Check backend health."""
|
|
90
|
+
url = f"{self.endpoint}/health"
|
|
91
|
+
with httpx.Client(timeout=self.timeout) as http:
|
|
92
|
+
resp = http.get(url)
|
|
93
|
+
resp.raise_for_status()
|
|
94
|
+
return resp.json()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Data models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestInput(BaseModel):
|
|
8
|
+
"""A single test input."""
|
|
9
|
+
text: str
|
|
10
|
+
category: str = "default"
|
|
11
|
+
metadata: dict = Field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestResult(BaseModel):
|
|
15
|
+
"""Result of processing a single input."""
|
|
16
|
+
input_text: str
|
|
17
|
+
output_text: str
|
|
18
|
+
score: int = Field(ge=1, le=5)
|
|
19
|
+
detail: str = ""
|
|
20
|
+
checker: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Summary(BaseModel):
|
|
24
|
+
"""Aggregated results."""
|
|
25
|
+
total: int
|
|
26
|
+
pass_count: int
|
|
27
|
+
fail_count: int
|
|
28
|
+
pass_rate: float = Field(ge=0.0, le=1.0)
|
|
29
|
+
threshold: float = Field(ge=0.0, le=1.0)
|
|
30
|
+
passed: bool
|
|
31
|
+
results: list[TestResult] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JobRequest(BaseModel):
|
|
35
|
+
"""Request to run a job."""
|
|
36
|
+
inputs: list[str]
|
|
37
|
+
target: str
|
|
38
|
+
threshold: float = 0.8
|
|
39
|
+
tags: list[str] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JobResponse(BaseModel):
|
|
43
|
+
"""Response from a job."""
|
|
44
|
+
job_id: str
|
|
45
|
+
status: str = "pending"
|
|
46
|
+
summary: Summary | None = None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Plugin registry for swappable components."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol, runtime_checkable, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class Checker(Protocol):
|
|
9
|
+
"""Scores an input/output pair. Returns 1-5 (1=pass, 5=fail)."""
|
|
10
|
+
def check(self, input_text: str, output_text: str) -> int: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class Processor(Protocol):
|
|
15
|
+
"""Sends an input to a target and returns the response."""
|
|
16
|
+
def process(self, input_text: str, target: str) -> str: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class DataSource(Protocol):
|
|
21
|
+
"""Provides inputs for processing."""
|
|
22
|
+
def load(self, tags: list[str] | None = None) -> list[str]: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class Reporter(Protocol):
|
|
27
|
+
"""Formats results for output."""
|
|
28
|
+
def report(self, summary: Any) -> str: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Scorer(Protocol):
|
|
33
|
+
"""Aggregates results and applies pass/fail logic."""
|
|
34
|
+
def score(self, results: list[Any], threshold: float) -> Any: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class AuthProvider(Protocol):
|
|
39
|
+
"""Signs outbound HTTP requests."""
|
|
40
|
+
def sign(self, method: str, url: str, body: bytes = b"") -> dict[str, str]: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NotFoundError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ComponentNotFoundError = _NotFoundError
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Registry:
|
|
51
|
+
"""Singleton registry for pluggable components.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
# Register a default (shipped with the package)
|
|
55
|
+
Registry.register_default("checker", StubChecker())
|
|
56
|
+
|
|
57
|
+
# Override with a custom implementation (Amazon-internal)
|
|
58
|
+
Registry.register("checker", ComprehendPiiChecker())
|
|
59
|
+
|
|
60
|
+
# Retrieve (custom > default > error)
|
|
61
|
+
checker = Registry.get("checker")
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
_defaults: dict[str, object] = {}
|
|
65
|
+
_custom: dict[str, object] = {}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def register_default(cls, key: str, component: object) -> None:
|
|
69
|
+
cls._defaults[key] = component
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def register(cls, key: str, component: object) -> None:
|
|
73
|
+
cls._custom[key] = component
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def get(cls, key: str) -> object:
|
|
77
|
+
if key in cls._custom:
|
|
78
|
+
return cls._custom[key]
|
|
79
|
+
if key in cls._defaults:
|
|
80
|
+
return cls._defaults[key]
|
|
81
|
+
raise ComponentNotFoundError(f"No component registered for '{key}'")
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def has(cls, key: str) -> bool:
|
|
85
|
+
return key in cls._custom or key in cls._defaults
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def clear(cls) -> None:
|
|
89
|
+
cls._custom.clear()
|
|
90
|
+
cls._defaults.clear()
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def keys(cls) -> list[str]:
|
|
94
|
+
return list(set(list(cls._defaults.keys()) + list(cls._custom.keys())))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Server entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from http.server import HTTPServer
|
|
6
|
+
|
|
7
|
+
from rascal.app import AppHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(host: str = "0.0.0.0", port: int | None = None):
|
|
11
|
+
"""Start the server."""
|
|
12
|
+
port = port or int(os.environ.get("PORT", "8080"))
|
|
13
|
+
server = HTTPServer((host, port), AppHandler)
|
|
14
|
+
print(f"Listening on {host}:{port}")
|
|
15
|
+
server.serve_forever()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
run()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""DynamoDB storage layer for jobs and data."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
from rascal.models import JobResponse, Summary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Storage:
|
|
14
|
+
"""Stores and retrieves jobs from DynamoDB."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
jobs_table: str | None = None,
|
|
19
|
+
data_table: str | None = None,
|
|
20
|
+
region: str | None = None,
|
|
21
|
+
):
|
|
22
|
+
self.jobs_table = jobs_table or os.environ.get("JOBS_TABLE", "rascal-jobs")
|
|
23
|
+
self.data_table = data_table or os.environ.get("DATA_TABLE", "rascal-data")
|
|
24
|
+
self._ddb = boto3.resource("dynamodb", region_name=region or os.environ.get("AWS_REGION"))
|
|
25
|
+
|
|
26
|
+
def save_job(self, job: JobResponse) -> None:
|
|
27
|
+
table = self._ddb.Table(self.jobs_table)
|
|
28
|
+
item: dict[str, Any] = {
|
|
29
|
+
"jobId": job.job_id,
|
|
30
|
+
"status": job.status,
|
|
31
|
+
"ttl": int(time.time()) + 86400,
|
|
32
|
+
}
|
|
33
|
+
if job.summary:
|
|
34
|
+
item["summary"] = json.loads(job.summary.model_dump_json())
|
|
35
|
+
table.put_item(Item=item)
|
|
36
|
+
|
|
37
|
+
def get_job(self, job_id: str) -> JobResponse | None:
|
|
38
|
+
table = self._ddb.Table(self.jobs_table)
|
|
39
|
+
resp = table.get_item(Key={"jobId": job_id})
|
|
40
|
+
item = resp.get("Item")
|
|
41
|
+
if not item:
|
|
42
|
+
return None
|
|
43
|
+
summary = None
|
|
44
|
+
if "summary" in item:
|
|
45
|
+
summary = Summary.model_validate(item["summary"])
|
|
46
|
+
return JobResponse(
|
|
47
|
+
job_id=item["jobId"],
|
|
48
|
+
status=item.get("status", "unknown"),
|
|
49
|
+
summary=summary,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def save_data(self, pk: str, sk: str, data: dict) -> None:
|
|
53
|
+
table = self._ddb.Table(self.data_table)
|
|
54
|
+
table.put_item(Item={"pk": pk, "sk": sk, **data})
|
|
55
|
+
|
|
56
|
+
def get_data(self, pk: str, sk: str) -> dict | None:
|
|
57
|
+
table = self._ddb.Table(self.data_table)
|
|
58
|
+
resp = table.get_item(Key={"pk": pk, "sk": sk})
|
|
59
|
+
return resp.get("Item")
|