Skip to content

Commit 86bc18c

Browse files
committed
feat(BA-2750): Add support for array in config generator
This change adds array of tables syntax [[table]] of TOML in the sample config generator. As the downstream change of adding multiple agents in agent runtime server will need the config to be able to express array of tables, having the sample generator be able to handle this would be beneficial.
1 parent 1fc4ac1 commit 86bc18c

File tree

3 files changed

+174
-19
lines changed

3 files changed

+174
-19
lines changed

changes/6311.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for array of tables syntax in config sample generator

configs/agent/sample.toml

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#
77
# Generated automatically from the AgentUnifiedConfig schema.
88

9+
# Agent configuration
910
[agent]
1011
# Backend type for the agent.
1112
# This determines how the agent interacts with the underlying infrastructure.
@@ -91,12 +92,14 @@
9192
# Owner uid:gid of the mount directory
9293
## mount-path-uid-gid = "root:root"
9394

95+
# Container lifecycle synchronization config
9496
[agent.sync-container-lifecycles]
9597
# Whether to enable container lifecycle synchronization
9698
enabled = true
9799
# Synchronization interval in seconds
98100
interval = 10.0
99101

102+
# Container configuration
100103
[container]
101104
# Kernel user ID
102105
kernel-uid = -1
@@ -126,20 +129,14 @@
126129
## scratch-nfs-options = "rw,sync"
127130
# Alternative bridge network
128131
## alternative-bridge = "br-backend"
129-
# KRunner volumes configuration, mapping container names to host paths.
130-
# This is used to specify volumes that should be mounted into containers
131-
# when using the KRunner backend.
132-
# This fields is filled by the agent at runtime based on the
133-
# `krunner_volumes` configuration in the agent's environment.
134-
# It is not intended to be set in the configuration file.
135-
## krunner-volumes = { }
136132
# Whether to enable Docker Swarm mode.
137133
# This allows the agent to manage containers in a Docker Swarm cluster.
138134
# When enabled, the agent will use Docker Swarm APIs to manage containers,
139135
# networks, and services.
140136
# This field is only used when backend is set to 'docker'.
141137
swarm-enabled = false
142138

139+
# Pyroscope configuration
143140
[pyroscope]
144141
# Whether to enable Pyroscope profiling
145142
enabled = false
@@ -150,6 +147,7 @@
150147
# Sampling rate for Pyroscope profiling
151148
## sample-rate = 10
152149

150+
# Logging configuration
153151
[logging]
154152
# The version used by logging.dictConfig().
155153
version = 1
@@ -159,9 +157,6 @@
159157
disable-existing-loggers = false
160158
# The list of log drivers to activate.
161159
drivers = [ "console",]
162-
## file = "{ FileConfig }"
163-
## logstash = "{ LogstashConfig }"
164-
## graylog = "{ GraylogConfig }"
165160

166161
# The mapping of log handler configurations.
167162
[logging.handlers]
@@ -175,9 +170,52 @@
175170
# Determine verbosity of log.
176171
format = "verbose"
177172

173+
[logging.file]
174+
# Path to store log.
175+
path = "/var/log/backend.ai"
176+
# Log file name.
177+
filename = "wsproxy.log"
178+
# Number of outdated log files to retain.
179+
backup-count = 5
180+
# Maximum size for a single log file.
181+
## rotation-size = "..." # | # min=0
182+
# Determine verbosity of log.
183+
format = "verbose"
184+
185+
[logging.logstash]
186+
# Connection information of logstash node.
187+
endpoint = { host = "127.0.0.1", port = 8001 }
188+
# Protocol to communicate with logstash server.
189+
protocol = "tcp"
190+
# Use TLS to communicate with logstash server.
191+
ssl-enabled = true
192+
# Verify validity of TLS certificate when communicating with logstash.
193+
ssl-verify = true
194+
195+
[logging.graylog]
196+
# Graylog hostname.
197+
host = "127.0.0.1"
198+
# Graylog server port number.
199+
port = 8000
200+
# Log level.
201+
level = "INFO"
202+
# The custom source identifier. If not specified, fqdn will be used instead.
203+
## localname = "..."
204+
# The fuly qualified domain name of the source.
205+
## fqdn = "..."
206+
# Verify validity of TLS certificate when communicating with logstash.
207+
ssl-verify = true
208+
# Path to Root CA certificate file.
209+
## ca-certs = "/etc/ssl/ca.pem"
210+
# Path to TLS private key file.
211+
## keyfile = "/etc/backend.ai/graylog/privkey.pem"
212+
# Path to TLS certificate file.
213+
## certfile = "/etc/backend.ai/graylog/cert.pem"
214+
178215
# Override default log level for specific scope of package
179216
[logging.pkg_ns]
180217

218+
# Resource configuration
181219
[resource]
182220
# The number of CPU cores reserved for the operating system and the agent
183221
# service.
@@ -201,6 +239,7 @@
201239
# Affinity policy
202240
affinity-policy = "INTERLEAVED"
203241

242+
# OpenTelemetry configuration
204243
[otel]
205244
# Whether to enable OpenTelemetry
206245
enabled = false
@@ -209,10 +248,12 @@
209248
# OTLP endpoint for sending traces
210249
endpoint = "http://127.0.0.1:4317"
211250

251+
# Service discovery configuration
212252
[service-discovery]
213253
# Type of service discovery to use
214254
type = "redis"
215255

256+
# Debug configuration
216257
[debug]
217258
# Master switch for debug mode
218259
enabled = false
@@ -239,6 +280,7 @@
239280
# Whether to log Docker events
240281
log-docker-events = false
241282

283+
# Core dump configuration
242284
[debug.coredump]
243285
# Whether to enable core dump collection
244286
enabled = false
@@ -249,6 +291,7 @@
249291
# Maximum size limit for core dumps
250292
size-limit = "64M"
251293

294+
# Etcd configuration
252295
[etcd]
253296
# Etcd namespace
254297
namespace = "local"
@@ -259,12 +302,14 @@
259302
# Etcd password
260303
## password = "PASSWORD"
261304

305+
# Container logs configuration
262306
[container-logs]
263307
# Maximum length of container logs
264308
max-length = "10M"
265309
# Chunk size for container logs
266310
chunk-size = "64K"
267311

312+
# API configuration
268313
[api]
269314
# Image pull timeout in seconds
270315
## pull-timeout = 7200.0 # min=0
@@ -273,6 +318,7 @@
273318
# Image push timeout in seconds
274319
## push-timeout = 7200.0 # min=0
275320

321+
# Kernel lifecycles configuration
276322
[kernel-lifecycles]
277323
# Number of init polling attempts
278324
init-polling-attempt = 10

src/ai/backend/common/configs/sample_generator.py

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ class _InlineTable(dict, InlineTableDict):
4949
pass
5050

5151

52+
def _is_runtime_field(description: str) -> bool:
53+
return "at runtime" in description
54+
55+
5256
def _wrap_comment(text: str, prefix: str = "", width: int = 80) -> str:
5357
"""Wrap text into multiline comment format."""
5458
lines = text.strip().split("\n")
@@ -122,7 +126,7 @@ def _dump_toml_scalar(
122126
case "BinarySize":
123127
value = f"{BinarySize(value):s}".upper()
124128
case "HostPortPair":
125-
value = {"host": value.host, "port": value.port}
129+
value = {"host": value["host"], "port": value["port"]}
126130
case "EnumByValue":
127131
assert ctx.annotation is not None
128132
value = ctx.annotation(value).value
@@ -193,7 +197,7 @@ def _get_field_info(model_cls: Type[BaseModel], field_name: str, indent: int) ->
193197
try:
194198
factory_instance = field.default_factory() # type: ignore
195199
if isinstance(factory_instance, BaseModel):
196-
field_info["default"] = factory_instance.model_dump()
200+
field_info["default"] = factory_instance.model_dump(mode="python")
197201
else:
198202
field_info["default"] = factory_instance
199203
except Exception:
@@ -224,7 +228,7 @@ def _process_property(
224228
# Add description as comment if available
225229
description = field_info.get("description") or prop_schema.get("description")
226230
if description:
227-
if "This field is injected at runtime" in description:
231+
if _is_runtime_field(description):
228232
# Skip runtimme-generated fields.
229233
return []
230234
comment_lines = _wrap_comment(description)
@@ -332,8 +336,20 @@ def _process_schema(
332336
# Group properties by type
333337
simple_props = {}
334338
object_props = {}
339+
array_of_tables_props = {}
335340

336341
for prop_name, prop_schema in properties.items():
342+
original_prop_schema = prop_schema
343+
344+
# Handle anyOf (optional types) - unwrap to check the inner type
345+
if "anyOf" in prop_schema:
346+
# Check if this is an optional type (Type | None)
347+
any_of_items = prop_schema["anyOf"]
348+
non_null_items = [item for item in any_of_items if item != {"type": "null"}]
349+
if len(non_null_items) == 1 and len(any_of_items) == 2:
350+
# This is an optional type - unwrap it to check if it's an object
351+
prop_schema = non_null_items[0]
352+
337353
if "$ref" in prop_schema:
338354
# Resolve reference
339355
ref_path = prop_schema["$ref"].split("/")
@@ -345,12 +361,36 @@ def _process_schema(
345361

346362
prop_type = prop_schema.get("type", "")
347363

348-
if (prop_type == "object" or "properties" in prop_schema) and prop_schema[
364+
# Check if this is an array of objects (array of tables in TOML)
365+
if prop_type == "array" and "items" in prop_schema:
366+
items_schema = prop_schema["items"]
367+
# Resolve $ref in items if present
368+
if "$ref" in items_schema:
369+
ref_path = items_schema["$ref"].split("/")
370+
if ref_path[0] == "#" and len(ref_path) > 1:
371+
resolved = schema
372+
for part in ref_path[1:]:
373+
resolved = resolved.get(part, {})
374+
items_schema = resolved
375+
376+
# Check if the items are objects (complex types)
377+
if items_schema.get("type") == "object" or "properties" in items_schema:
378+
array_of_tables_props[prop_name] = (original_prop_schema, items_schema)
379+
continue
380+
381+
# Check if this is a complex object that should be expanded
382+
if (prop_type == "object" or "properties" in prop_schema) and prop_schema.get(
349383
"title"
350-
] != "HostPortPair":
384+
) != "HostPortPair":
385+
# Preserve description from original schema if it was unwrapped
386+
if "description" in original_prop_schema and "description" not in prop_schema:
387+
prop_schema = {
388+
**prop_schema,
389+
"description": original_prop_schema["description"],
390+
}
351391
object_props[prop_name] = prop_schema
352392
else:
353-
simple_props[prop_name] = prop_schema
393+
simple_props[prop_name] = original_prop_schema
354394

355395
# Add simple properties first
356396
processed_simple_props = []
@@ -360,6 +400,7 @@ def _process_schema(
360400
)
361401
if prop_lines:
362402
lines.extend(prop_lines)
403+
# Exclude runtime-injected fields from the warning
363404
processed_simple_props.append(prop_name)
364405

365406
if path == [] and processed_simple_props:
@@ -368,19 +409,25 @@ def _process_schema(
368409
"The configuration schema CANNOT have simple fields in the root "
369410
"without any section header according to the TOML specification. "
370411
"Also, optional sections should be defined non-optional with explicit default factory. "
371-
f"Please move or fix these fields/sections: {', '.join(simple_props.keys())}. "
412+
f"Please move or fix these fields/sections: {', '.join(processed_simple_props)}. "
372413
)
373414

374415
# Add object properties as sections
375416
for prop_name, prop_schema in object_props.items():
376417
indent_str = " " * len(path)
377418

419+
# Skip if this is a runtime-injected field
420+
description = prop_schema.get("description", "")
421+
if _is_runtime_field(description):
422+
continue
423+
378424
if lines and lines[-1].strip(): # Add blank line before section
379425
lines.append("")
380426

381427
# Add section comment
382-
if "description" in prop_schema:
383-
comment_lines = _wrap_comment(prop_schema["description"], prefix=indent_str)
428+
description = prop_schema.get("description", "")
429+
if description:
430+
comment_lines = _wrap_comment(description, prefix=indent_str)
384431
lines.extend(comment_lines.split("\n"))
385432

386433
# Add section header
@@ -422,6 +469,67 @@ def _process_schema(
422469
)
423470
lines.extend(nested_lines)
424471

472+
# Add array of tables properties using [[array.name]] syntax
473+
for prop_name, (prop_schema, items_schema) in array_of_tables_props.items():
474+
indent_str = " " * len(path)
475+
476+
# Skip if this is a runtime-injected field
477+
description = prop_schema.get("description", "")
478+
if _is_runtime_field(description):
479+
continue
480+
481+
if lines and lines[-1].strip(): # Add blank line before section
482+
lines.append("")
483+
484+
# Add array of tables comment
485+
if description:
486+
comment_lines = _wrap_comment(description, prefix=indent_str)
487+
lines.extend(comment_lines.split("\n"))
488+
489+
# Add array of tables header with double brackets [[array.name]]
490+
section_path = path + [prop_name]
491+
array_header = f"{indent_str}[[{'.'.join(section_path)}]]"
492+
lines.append(array_header)
493+
print(array_header)
494+
495+
# Add a comment about adding multiple entries
496+
lines.append(
497+
f"{indent_str}# Add multiple [[{'.'.join(section_path)}]] sections as needed"
498+
)
499+
500+
# Process nested properties for the item schema
501+
nested_model_cls = None
502+
if model_cls and hasattr(model_cls, "model_fields"):
503+
field_info = _get_field_info(model_cls, prop_name, indent=len(path))
504+
if field_info:
505+
# Try to find the field and extract the item type from list annotation
506+
field = None
507+
if prop_name in model_cls.model_fields:
508+
field = model_cls.model_fields[prop_name]
509+
else:
510+
# Search by alias
511+
for finfo in model_cls.model_fields.values():
512+
if (
513+
hasattr(finfo, "serialization_alias")
514+
and finfo.serialization_alias == prop_name
515+
):
516+
field = finfo
517+
break
518+
519+
if field:
520+
if hasattr(field, "annotation") and hasattr(field.annotation, "__origin__"):
521+
# Handle generic types like list[SubAgentConfig]
522+
args = getattr(field.annotation, "__args__", ())
523+
if args and hasattr(args[0], "model_fields"):
524+
nested_model_cls = args[0]
525+
elif hasattr(field.annotation, "model_fields"):
526+
nested_model_cls = field.annotation
527+
528+
nested_lines = _process_schema(
529+
items_schema, path=section_path, parent_required=[], model_cls=nested_model_cls
530+
)
531+
lines.extend(nested_lines)
532+
425533
return lines
426534

427535
# Process the root schema

0 commit comments

Comments
 (0)