|
7 | 7 | # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the |
8 | 8 | # specific language governing permissions and limitations under the License. |
9 | 9 |
|
| 10 | +import ast |
| 11 | +import json |
| 12 | +import jsonschema |
10 | 13 | import ipaddress |
| 14 | +from dataclasses import dataclass |
11 | 15 | from typing import Dict, List, Optional, Tuple |
12 | 16 |
|
| 17 | + |
| 18 | +from .constants import Draft7Schema |
13 | 19 | from .utils import literal_unicode, yaml_dump, yaml_text_indent |
14 | 20 |
|
15 | 21 |
|
| 22 | +VARS_ALLOWED_COMPARISON_SYMBOLS = {"==", "~=", ">", ">=", "<", "<=", "~~", "~*", "in", "has", "!", "ipmatch"} |
| 23 | + |
| 24 | + |
16 | 25 | def build_bk_header_rewrite(set: Dict[str, str], remove: List[str]) -> Dict[str, str]: |
17 | 26 | """generate bk-header-rewrite plugin config |
18 | 27 |
|
@@ -265,3 +274,349 @@ def build_stage_plugin_config_for_definition_yaml( |
265 | 274 | } |
266 | 275 | ) |
267 | 276 | return indented_plugin_configs |
| 277 | + |
| 278 | + |
| 279 | +def build_bk_mock( |
| 280 | + response_example: str, |
| 281 | + response_headers: Dict[str, str], |
| 282 | + response_status: int = 200, |
| 283 | +) -> Dict[str, str]: |
| 284 | + """generate bk-mock plugin config |
| 285 | +
|
| 286 | + Args: |
| 287 | + response_example (str): 响应体。 |
| 288 | + response_headers (Dict[str, str]): 响应头。 |
| 289 | + response_status (int, optional): 响应状态码。Defaults to 200. |
| 290 | +
|
| 291 | + Raises: |
| 292 | + ValueError: response_status cannot be less than 100 |
| 293 | + ValueError: key {} can not contain ':' |
| 294 | +
|
| 295 | + Returns: |
| 296 | + { |
| 297 | + "type": "bk-mock", |
| 298 | + "yaml": "response_headers:\n- key: key1\nvalue: value1\n- key: key2\nvalue: value2\nresponse_status: 200\nresponse_example: ''" |
| 299 | + } |
| 300 | + """ |
| 301 | + if response_status < 100: |
| 302 | + raise ValueError("response_status cannot be less than 100") |
| 303 | + |
| 304 | + response_header_data = [] |
| 305 | + for k, v in response_headers.items(): |
| 306 | + if ":" in k: |
| 307 | + raise ValueError(f"key {k} can not contain ':'") |
| 308 | + response_header_data.append({"key": k, "value": v}) |
| 309 | + |
| 310 | + return { |
| 311 | + "type": "bk-mock", |
| 312 | + "yaml": yaml_dump( |
| 313 | + { |
| 314 | + "response_example": response_example, |
| 315 | + "response_headers": response_header_data, |
| 316 | + "response_status": response_status, |
| 317 | + } |
| 318 | + ), |
| 319 | + } |
| 320 | + |
| 321 | + |
| 322 | +@dataclass |
| 323 | +class UnhealthyConfig: |
| 324 | + http_statuses: List[int] = None |
| 325 | + failures: int = 3 |
| 326 | + |
| 327 | + |
| 328 | +@dataclass |
| 329 | +class HealthyConfig: |
| 330 | + http_statuses: List[int] = None |
| 331 | + successes: int = 3 |
| 332 | + |
| 333 | + |
| 334 | +def build_api_breaker( |
| 335 | + break_response_body: str, |
| 336 | + break_response_headers: Dict[str, str], |
| 337 | + unhealthy: UnhealthyConfig, |
| 338 | + healthy: HealthyConfig, |
| 339 | + break_response_code: int = 502, |
| 340 | + max_breaker_sec: int = 300, |
| 341 | +) -> Dict[str, str]: |
| 342 | + """generate api-breaker plugin config |
| 343 | +
|
| 344 | + Args: |
| 345 | + break_response_body (str): 当上游服务处于不健康状态时返回的 HTTP 响应体信息。 |
| 346 | + break_response_headers (Dict[str, str]): 当上游服务处于不健康状态时返回的 HTTP 响应头信息。 |
| 347 | + unhealthy (UnhealthyConfig): 当上游服务处于不健康状态时的 HTTP 的状态码和异常请求次数。 |
| 348 | + - 应包含键 "http_statuses"(不健康状态码列表)和"failures"(触发不健康状态的异常请求次数) |
| 349 | + healthy (HealthyConfig): 上游服务处于健康状态时的 HTTP 状态码和连续正常请求次数。 |
| 350 | + - 应包含键 "http_statuses"(健康状态码列表)和"successes"(触发健康状态的连续正常请求次数) |
| 351 | + break_response_code (int, optional): 当上游服务处于不健康状态时返回的 HTTP 错误码。Defaults to 502. |
| 352 | + max_breaker_sec (int, optional): 上游服务熔断的最大持续时间,以秒为单位,最小 3 秒。Defaults to 300. |
| 353 | +
|
| 354 | + Raises: |
| 355 | + ValueError: max_breaker_sec duration cannot be less than 3 seconds |
| 356 | + ValueError: unhealthy status must be between 500 and 599 |
| 357 | + ValueError: unhealthy failures must be greater than or equal to 1 |
| 358 | + ValueError: healthy status must be between 200 and 499 |
| 359 | + ValueError: healthy successes must be greater than or equal to 1 |
| 360 | + ValueError: key {} can not contain ':' |
| 361 | +
|
| 362 | + Returns: |
| 363 | + { |
| 364 | + "type": "api-breaker", |
| 365 | + "yaml": "break_response_code: 502\nbreak_response_body: ''\nbreak_response_headers:\n - key: key1\n value: value1\nmax_breaker_sec: 300\nunhealthy:\n http_statuses:\n - 503\n failures: 3\nhealthy:\n http_statuses:\n - 200\n successes: 3" |
| 366 | + } |
| 367 | + """ |
| 368 | + |
| 369 | + if break_response_code is None: |
| 370 | + raise ValueError("break_response_code is required") |
| 371 | + |
| 372 | + if not (200 <= break_response_code <= 599): |
| 373 | + raise ValueError("break_response_code must be between 200 and 599") |
| 374 | + |
| 375 | + if max_breaker_sec and max_breaker_sec < 3: |
| 376 | + raise ValueError("max_breaker_sec duration cannot be less than 3 seconds") |
| 377 | + |
| 378 | + unhealthy_http_statuses = unhealthy.http_statuses or [500] |
| 379 | + for status in unhealthy_http_statuses: |
| 380 | + if not (500 <= status <= 599): |
| 381 | + raise ValueError("unhealthy status must be between 500 and 599") |
| 382 | + |
| 383 | + unhealthy_failures = unhealthy.failures |
| 384 | + if unhealthy_failures is not None and unhealthy_failures < 1: |
| 385 | + raise ValueError("unhealthy failures must be greater than or equal to 1") |
| 386 | + |
| 387 | + healthy_http_statuses = healthy.http_statuses or [200] |
| 388 | + for status in healthy_http_statuses: |
| 389 | + if not (200 <= status <= 499): |
| 390 | + raise ValueError("healthy status must be between 200 and 499") |
| 391 | + |
| 392 | + healthy_successes = healthy.successes |
| 393 | + if healthy_successes is not None and healthy_successes < 1: |
| 394 | + raise ValueError("healthy successes must be greater than or equal to 1") |
| 395 | + |
| 396 | + break_response_header_data = [] |
| 397 | + for k, v in break_response_headers.items(): |
| 398 | + if ":" in k: |
| 399 | + raise ValueError(f"key {k} can not contain ':'") |
| 400 | + break_response_header_data.append({"key": k, "value": v}) |
| 401 | + |
| 402 | + return { |
| 403 | + "type": "api-breaker", |
| 404 | + "yaml": yaml_dump( |
| 405 | + { |
| 406 | + "break_response_code": break_response_code, |
| 407 | + "break_response_body": break_response_body, |
| 408 | + "break_response_headers": break_response_header_data, |
| 409 | + "max_breaker_sec": max_breaker_sec, |
| 410 | + "unhealthy": { |
| 411 | + "http_statuses": unhealthy_http_statuses, |
| 412 | + "failures": unhealthy_failures |
| 413 | + }, |
| 414 | + "healthy": { |
| 415 | + "http_statuses": healthy_http_statuses, |
| 416 | + "successes": healthy_successes |
| 417 | + }, |
| 418 | + } |
| 419 | + ), |
| 420 | + } |
| 421 | + |
| 422 | + |
| 423 | +@dataclass |
| 424 | +class AbortConfig: |
| 425 | + http_status: int |
| 426 | + body: str |
| 427 | + percentage: int |
| 428 | + vars: str |
| 429 | + |
| 430 | + |
| 431 | +@dataclass |
| 432 | +class DelayConfig: |
| 433 | + duration: float |
| 434 | + percentage: int |
| 435 | + vars: str |
| 436 | + |
| 437 | + |
| 438 | +def build_fault_injection( |
| 439 | + abort: Optional[AbortConfig] = None, |
| 440 | + delay: Optional[DelayConfig] = None, |
| 441 | +) -> Dict[str, str]: |
| 442 | + """generate fault-injection plugin config |
| 443 | +
|
| 444 | + Args: |
| 445 | + abort (AbortConfig, optional): 中断状态。 |
| 446 | + - http_status (int): 返回给客户端的 HTTP 状态码。 |
| 447 | + - body (str): 返回给客户端的响应数据。支持使用 NGINX 变量,如 client addr: $remote_addr\n。 |
| 448 | + - percentage (int): 将被中断的请求占比(0-100)。 |
| 449 | + - vars (str): 执行故障注入的规则,当规则匹配通过后才会执行故障注。vars 是一个表达式的列表,来自 lua-resty-expr。 |
| 450 | + delay (DelayConfig, optional): 延迟状态。 |
| 451 | + - duration (number): 延迟时间,单位秒,只能填入整数。 |
| 452 | + - percentage (int): 将被延迟的请求占比(0-100) |
| 453 | + - vars (str): 执行请求延迟的规则,当规则匹配通过后才会延迟请求。vars 是一个表达式列表,来自 lua-resty-expr。 |
| 454 | +
|
| 455 | + Raises: |
| 456 | + ValueError: At least one of the conditions 'abort' or 'delay' must be configured |
| 457 | + ValueError: http_status is required in abort |
| 458 | + ValueError: http_status must be greater than 200 |
| 459 | + ValueError: The percentage of abort must be greater than 0 and less than or equal to 100 |
| 460 | + ValueError: duration is required in delay |
| 461 | +
|
| 462 | + Returns: |
| 463 | + { |
| 464 | + "type": "fault-injection", |
| 465 | + "yaml": "abort:\n body: ''\n http_status: 500\n percentage: 20\n vars: ''\ndelay:\n duration: 2.5\n percentage: 100\n vars: ''\n" |
| 466 | + } |
| 467 | + """ |
| 468 | + config = {} |
| 469 | + |
| 470 | + if not abort and not delay: |
| 471 | + raise ValueError("At least one of the conditions 'abort' or 'delay' must be configured") |
| 472 | + |
| 473 | + if abort: |
| 474 | + http_status = abort.http_status |
| 475 | + if not http_status: |
| 476 | + raise ValueError("http_status is required in abort") |
| 477 | + if http_status < 200: |
| 478 | + raise ValueError("http_status must be greater than 200") |
| 479 | + |
| 480 | + percentage = abort.percentage |
| 481 | + _check_percentage(percentage, "abort") |
| 482 | + |
| 483 | + abort_vars = abort.vars |
| 484 | + if abort_vars: |
| 485 | + _check_vars(abort_vars, "abort") |
| 486 | + |
| 487 | + config["abort"] = { |
| 488 | + "http_status": http_status, |
| 489 | + "body": abort.body, |
| 490 | + "percentage": percentage, |
| 491 | + "vars": abort_vars |
| 492 | + } |
| 493 | + |
| 494 | + if delay: |
| 495 | + if not delay.duration: |
| 496 | + raise ValueError("duration is required in delay") |
| 497 | + |
| 498 | + percentage = delay.percentage |
| 499 | + _check_percentage(percentage, "delay") |
| 500 | + |
| 501 | + delay_vars = delay.vars |
| 502 | + if delay_vars: |
| 503 | + _check_vars(delay_vars, "delay") |
| 504 | + |
| 505 | + config["delay"] = { |
| 506 | + "duration": delay.duration, |
| 507 | + "percentage": percentage, |
| 508 | + "vars": delay_vars |
| 509 | + } |
| 510 | + |
| 511 | + return { |
| 512 | + "type": "fault-injection", |
| 513 | + "yaml": yaml_dump(config) |
| 514 | + } |
| 515 | + |
| 516 | + |
| 517 | +def build_request_validation( |
| 518 | + body_schema: str, |
| 519 | + header_schema: str, |
| 520 | + rejected_msg: str, |
| 521 | + rejected_code: int = 400, |
| 522 | +) -> Dict[str, str]: |
| 523 | + """generate request-validation plugin config |
| 524 | +
|
| 525 | + Args: |
| 526 | + body_schema (str): request body 数据的 JSON Schema。 |
| 527 | + header_schema (str): request header 数据的 JSON Schema。 |
| 528 | + rejected_msg (str): 拒绝信息。 |
| 529 | + rejected_code (int, optional): 拒绝状态码。Defaults to 400. |
| 530 | +
|
| 531 | + Raises: |
| 532 | + ValueError: rejected_code must be between 200 and 599 |
| 533 | + ValueError: header_schema or body_schema should be configured at least one |
| 534 | + ValueError: Your {schema_name} Schema is not a valid JSON |
| 535 | + ValueError: Your {schema_name} Schema is not valid: {err} |
| 536 | +
|
| 537 | + Returns: |
| 538 | + { |
| 539 | + "type": "request-validation", |
| 540 | + "yaml": 'body_schema: \'{"type": "object"}\'\nheader_schema: \'{"type": "object"}\'\nrejected_code: 403\nrejected_msg: test\n' |
| 541 | + } |
| 542 | + """ |
| 543 | + config = { |
| 544 | + "rejected_code": rejected_code, |
| 545 | + "rejected_msg": rejected_msg |
| 546 | + } |
| 547 | + |
| 548 | + if not (200 <= rejected_code <= 599): |
| 549 | + raise ValueError("rejected_code must be between 200 and 599") |
| 550 | + |
| 551 | + if not body_schema and not header_schema: |
| 552 | + raise ValueError("header_schema or body_schema should be configured at least one") |
| 553 | + |
| 554 | + if body_schema: |
| 555 | + _validate_json_schema("body_schema", body_schema) |
| 556 | + config["body_schema"] = body_schema |
| 557 | + |
| 558 | + if header_schema: |
| 559 | + _validate_json_schema("header_schema", header_schema) |
| 560 | + config["header_schema"] = header_schema |
| 561 | + |
| 562 | + return { |
| 563 | + "type": "request-validation", |
| 564 | + "yaml": yaml_dump(config) |
| 565 | + } |
| 566 | + |
| 567 | + |
| 568 | +def _check_percentage(percentage: int, location: str): |
| 569 | + if percentage and not (0 < percentage <= 100): |
| 570 | + raise ValueError(f"The percentage of {location} must be greater than 0 and less than or equal to 100") |
| 571 | + |
| 572 | + |
| 573 | +def _check_vars(vars: str, location: str): |
| 574 | + """check vars of lua-resty-expr |
| 575 | + vars = `[ |
| 576 | + [ |
| 577 | + [ "arg_name","==","jack" ], |
| 578 | + [ "arg_age","==",18 ] |
| 579 | + ], |
| 580 | + [ |
| 581 | + [ "arg_name2","==111","allen" ] |
| 582 | + ] |
| 583 | + ]` |
| 584 | +
|
| 585 | + """ |
| 586 | + try: |
| 587 | + parsed_vars = ast.literal_eval(vars) |
| 588 | + except Exception as e: |
| 589 | + raise ValueError(f"The vars of {location} is not valid, error: {e}") |
| 590 | + |
| 591 | + # 第一层 parsed_vars = [ [a], [] ] |
| 592 | + if not isinstance(parsed_vars, list): |
| 593 | + raise TypeError(f"The vars of {location} should be list") |
| 594 | + |
| 595 | + for index, v in enumerate(parsed_vars): |
| 596 | + # 中间层 v = [a] |
| 597 | + if not isinstance(v, list): |
| 598 | + raise TypeError(f"The vars of {location} at index {index} should be list") |
| 599 | + |
| 600 | + for i, item in enumerate(v): |
| 601 | + # 最内侧 a = [ "arg_name","==","jack" ] |
| 602 | + if isinstance(item, list): |
| 603 | + if len(item) != 3: |
| 604 | + raise ValueError(f"The vars of {location} at index [{index}][{i}] should have 3 elements") |
| 605 | + if item[1] not in VARS_ALLOWED_COMPARISON_SYMBOLS: |
| 606 | + raise ValueError( |
| 607 | + f"The vars of {location} at index [{index}][{i}] should have a valid comparison symbol" |
| 608 | + ) |
| 609 | + else: |
| 610 | + raise TypeError(f"The vars of {location} at index [{index}][{i}] should be list") |
| 611 | + |
| 612 | + |
| 613 | +def _validate_json_schema(schema_name: str, json_schema: str): |
| 614 | + try: |
| 615 | + data = json.loads(json_schema) |
| 616 | + except json.JSONDecodeError: |
| 617 | + raise ValueError(f"Your {schema_name} Schema is not a valid JSON") |
| 618 | + |
| 619 | + try: |
| 620 | + jsonschema.validate(instance=data, schema=Draft7Schema) |
| 621 | + except jsonschema.exceptions.ValidationError as err: |
| 622 | + raise ValueError(f"Your {schema_name} Schema is not valid: {err}") |
0 commit comments