Skip to content

Commit 2c4adbd

Browse files
authored
Merge pull request #43 from godon-dev/feature_targets
feat: add target catalog with CRUD scripts and targetRefs resolution
2 parents 56e7d39 + 5b8f2bf commit 2c4adbd

11 files changed

Lines changed: 737 additions & 461 deletions

File tree

.github/workflows/ci.yml

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,20 @@ jobs:
8080
sys.modules['f.shared'] = fake_f.shared
8181
sys.modules['f.shared.otel_logging'] = fake_f.shared.otel_logging
8282
83+
fake_controller_shared = FakeControllerModule()
84+
fake_controller_otel = MagicMock()
85+
fake_controller_otel.get_logger = lambda name: MagicMock()
86+
fake_controller_shared.otel_logging = fake_controller_otel
87+
sys.modules['f.controller.shared'] = fake_controller_shared
88+
sys.modules['f.controller.shared.otel_logging'] = fake_controller_otel
89+
8390
# Pre-populate all f.controller.xxx modules BEFORE any imports
8491
for module_name in ['config', 'database', 'breeder_service', 'breeder_create',
8592
'breeder_get', 'breeder_delete', 'breeders_get',
8693
'breeder_stop', 'breeder_start',
8794
'credential_create', 'credential_get', 'credential_delete',
88-
'credentials_get']:
95+
'credentials_get',
96+
'target_create', 'target_get', 'targets_get', 'target_delete']:
8997
stub = FakeControllerModule()
9098
sys.modules[f'f.controller.{module_name}'] = stub
9199
@@ -134,6 +142,18 @@ jobs:
134142
import controller.credentials_get as credentials_get
135143
populate_stub_module(sys.modules['f.controller.credentials_get'], credentials_get)
136144
145+
import controller.target_create as target_create
146+
populate_stub_module(sys.modules['f.controller.target_create'], target_create)
147+
148+
import controller.target_get as target_get
149+
populate_stub_module(sys.modules['f.controller.target_get'], target_get)
150+
151+
import controller.targets_get as targets_get
152+
populate_stub_module(sys.modules['f.controller.targets_get'], targets_get)
153+
154+
import controller.target_delete as target_delete
155+
populate_stub_module(sys.modules['f.controller.target_delete'], target_delete)
156+
137157
# Test imports
138158
from controller.config import DatabaseConfig
139159
from controller.breeder_service import BreederService
@@ -144,6 +164,8 @@ jobs:
144164
from controller.breeders_get import main as list_breeders
145165
from controller.breeder_stop import main as stop_breeder
146166
from controller.breeder_start import main as start_breeder
167+
from controller.target_create import main as create_target
168+
from controller.target_get import main as get_target
147169
148170
# Setup test config - use the actual database names
149171
# Meta DB connection
@@ -185,6 +207,18 @@ jobs:
185207
execute_ddl_query(admin_config, 'CREATE DATABASE archive_db;')
186208
print('✓ Created archive_db')
187209
210+
# Create a target first (breeder configs now require targetRefs)
211+
print('Creating test target for breeder config...')
212+
target_result = create_target(request_data={
213+
'name': 'test-target',
214+
'targetType': 'ssh',
215+
'address': 'test.local',
216+
'username': 'test_user'
217+
})
218+
assert target_result['result'] == 'SUCCESS', f'Target create failed: {target_result}'
219+
test_target_id = target_result['data']['id']
220+
print(f'✓ Created test target: {test_target_id}')
221+
188222
# Test 1: Create breeder (tests schema creation + data insertion)
189223
print('Testing breeder creation (schema + data)...')
190224
breeder_config = {
@@ -203,12 +237,7 @@ jobs:
203237
}
204238
},
205239
'effectuation': {
206-
'targets': [{
207-
'type': 'ssh',
208-
'address': 'test.local',
209-
'username': 'test_user',
210-
'credentialName': 'test-ssh-key'
211-
}]
240+
'targetRefs': [test_target_id]
212241
},
213242
'cooperation': {'active': False},
214243
'objectives': [{'name': 'test_metric', 'goal': 'MINIMIZE'}],
@@ -395,12 +424,20 @@ jobs:
395424
sys.modules['f.shared'] = fake_f.shared
396425
sys.modules['f.shared.otel_logging'] = fake_f.shared.otel_logging
397426
427+
fake_controller_shared = FakeControllerModule()
428+
fake_controller_otel = MagicMock()
429+
fake_controller_otel.get_logger = lambda name: MagicMock()
430+
fake_controller_shared.otel_logging = fake_controller_otel
431+
sys.modules['f.controller.shared'] = fake_controller_shared
432+
sys.modules['f.controller.shared.otel_logging'] = fake_controller_otel
433+
398434
# Pre-populate all f.controller.xxx modules BEFORE any imports
399435
for module_name in ['config', 'database', 'breeder_service', 'breeder_create',
400436
'breeder_get', 'breeder_delete', 'breeders_get',
401437
'breeder_stop', 'breeder_start',
402438
'credential_create', 'credential_get', 'credential_delete',
403-
'credentials_get']:
439+
'credentials_get',
440+
'target_create', 'target_get', 'targets_get', 'target_delete']:
404441
stub = FakeControllerModule()
405442
sys.modules[f'f.controller.{module_name}'] = stub
406443
@@ -450,6 +487,18 @@ jobs:
450487
import controller.credentials_get as credentials_get
451488
populate_stub_module(sys.modules['f.controller.credentials_get'], credentials_get)
452489
490+
import controller.target_create as target_create
491+
populate_stub_module(sys.modules['f.controller.target_create'], target_create)
492+
493+
import controller.target_get as target_get
494+
populate_stub_module(sys.modules['f.controller.target_get'], target_get)
495+
496+
import controller.targets_get as targets_get
497+
populate_stub_module(sys.modules['f.controller.targets_get'], targets_get)
498+
499+
import controller.target_delete as target_delete
500+
populate_stub_module(sys.modules['f.controller.target_delete'], target_delete)
501+
453502
# Test imports
454503
from controller.config import DatabaseConfig
455504
from controller.database import execute_query, execute_ddl_query

controller/breeder_service.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,61 @@ def normalize_dict(obj):
218218
if 'settings' in config:
219219
config['settings'] = normalize_dict(config['settings'])
220220

221+
def _resolve_target_refs(self, breeder_config):
222+
"""Resolve targetRefs to inline targets from the targets catalog
223+
224+
If effectuation.targetRefs is present, fetches each target from
225+
the metadata DB and populates effectuation.targets with the resolved data.
226+
Backward compatible: if targetRefs is absent, targets are used as-is.
227+
228+
Args:
229+
breeder_config: Breeder configuration dict (modified in place)
230+
231+
Raises:
232+
ValueError: If any target ref cannot be resolved
233+
"""
234+
target_refs = breeder_config.get('effectuation', {}).get('targetRefs', [])
235+
if not target_refs:
236+
return
237+
238+
logger.info(f"Resolving {len(target_refs)} target references")
239+
240+
effectuation = breeder_config.get('effectuation', {})
241+
effectuation_type = effectuation.get('type', 'ssh')
242+
243+
resolved_targets = []
244+
meta_db = MetadataDatabaseRepository(DatabaseConfig.META_DB)
245+
meta_db.create_targets_table()
246+
247+
for ref_id in target_refs:
248+
target_row = meta_db.fetch_target_by_id(ref_id)
249+
if not target_row:
250+
target_row = meta_db.fetch_target_by_name(ref_id)
251+
if not target_row:
252+
raise ValueError(
253+
f"Cannot resolve target reference '{ref_id}'. "
254+
f"Target not found in catalog by ID or name."
255+
)
256+
257+
target_entry = {
258+
'id': str(target_row[0]),
259+
'type': target_row[2],
260+
'address': target_row[3],
261+
}
262+
263+
if target_row[4]:
264+
target_entry['username'] = target_row[4]
265+
if target_row[5]:
266+
target_entry['credentialId'] = target_row[5]
267+
if target_row[6]:
268+
target_entry['description'] = target_row[6]
269+
270+
resolved_targets.append(target_entry)
271+
logger.info(f"Resolved target ref '{ref_id}' -> {target_row[1]} ({target_row[3]})")
272+
273+
breeder_config['effectuation']['targets'] = resolved_targets
274+
logger.info(f"Resolved {len(resolved_targets)} targets from {len(target_refs)} references")
275+
221276
def create_breeder(self, breeder_config, name):
222277
"""Create a new breeder instance
223278
@@ -232,6 +287,8 @@ def create_breeder(self, breeder_config, name):
232287
breeder_id = None
233288

234289
try:
290+
self._resolve_target_refs(breeder_config)
291+
235292
BreederConfig.validate_minimal(breeder_config)
236293

237294
breeder_instance_name = name

controller/config.py

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -451,10 +451,12 @@ def validate_minimal(breeder_config, strict_mode=True):
451451
"Example: objectives: [{name: 'latency', goal: 'MINIMIZE', reconnaissance: {...}}]"
452452
)
453453

454-
if not breeder_config.get('effectuation', {}).get('targets') or len(breeder_config.get('effectuation', {}).get('targets', [])) == 0:
454+
target_refs = breeder_config.get('effectuation', {}).get('targetRefs')
455+
if not target_refs or not isinstance(target_refs, list) or len(target_refs) == 0:
455456
errors.append(
456-
"Missing or empty effectuation.targets array. "
457-
"Example: effectuation: {targets: [{type: 'ssh', address: '1.2.3.4', ...}]}"
457+
"Missing or empty effectuation.targetRefs. "
458+
"Targets must be registered via the API first, then referenced by ID or name. "
459+
"Example: effectuation: {targetRefs: ['my-server', '550e8400-...']}"
458460
)
459461

460462
# Support multiple settings categories (sysctl, sysfs, cpufreq, ethtool)
@@ -559,64 +561,14 @@ def validate_minimal(breeder_config, strict_mode=True):
559561
"Cooperation requires parallel > 1 for multiple workers to share trials."
560562
)
561563

562-
# Validate target type compatibility and required fields
564+
# Validate targetRefs
563565
breeder_type = breeder_config.get('breeder', {}).get('type')
564-
if breeder_type in BREEDER_CAPABILITIES:
565-
supported_types = BREEDER_CAPABILITIES[breeder_type]['supported_target_types']
566+
target_refs = breeder_config.get('effectuation', {}).get('targetRefs', [])
566567

567-
for idx, target in enumerate(breeder_config.get('effectuation', {}).get('targets', [])):
568-
target_type = target.get('type')
569-
570-
if not target_type:
571-
errors.append(f"Target {idx}: Missing required 'type' field. Example: type: 'ssh'")
572-
elif target_type not in supported_types:
573-
errors.append(
574-
f"Target {idx} ({target.get('address', 'unknown')}): "
575-
f"Type '{target_type}' not supported by breeder '{breeder_type}'. "
576-
f"Supported types: {supported_types}"
577-
)
578-
579-
# Validate SSH-specific target fields
580-
if target_type == 'ssh':
581-
# Check address
582-
if 'address' not in target:
583-
errors.append(
584-
f"Target {idx}: Missing required field 'address' for SSH target. "
585-
f"Example: address: '192.168.1.10'"
586-
)
587-
elif not isinstance(target['address'], str) or target['address'].strip() == "":
588-
errors.append(
589-
f"Target {idx}: 'address' must be a non-empty string"
590-
)
591-
592-
# Check username
593-
if 'username' not in target:
594-
errors.append(
595-
f"Target {idx}: Missing required field 'username' for SSH target. "
596-
f"Example: username: 'admin'"
597-
)
598-
elif not isinstance(target['username'], str) or target['username'].strip() == "":
599-
errors.append(
600-
f"Target {idx}: 'username' must be a non-empty string"
601-
)
602-
603-
# Check credential (either credentialName or credentialId)
604-
has_credential_name = 'credentialName' in target
605-
has_credential_id = 'credentialId' in target
606-
607-
if not has_credential_name and not has_credential_id:
608-
errors.append(
609-
f"Target {idx}: SSH target requires either 'credentialName' or 'credentialId'. "
610-
f"Example: credentialName: 'my-ssh-key'"
611-
)
612-
elif has_credential_name and (not isinstance(target['credentialName'], str) or target['credentialName'].strip() == ""):
613-
errors.append(
614-
f"Target {idx}: 'credentialName' must be a non-empty string"
615-
)
616-
elif has_credential_id and (not isinstance(target['credentialId'], str) or target['credentialId'].strip() == ""):
617-
errors.append(
618-
f"Target {idx}: 'credentialId' must be a non-empty string"
619-
)
568+
if target_refs and isinstance(target_refs, list):
569+
for idx, ref in enumerate(target_refs):
570+
if not isinstance(ref, str) or ref.strip() == "":
571+
errors.append(f"targetRefs[{idx}]: must be a non-empty string (target ID or name)")
620572

621573
# Validate objectives reconnaissance configuration
622574
for idx, objective in enumerate(breeder_config.get('objectives', [])):

controller/database.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def __init__(self, base_config):
142142
self.base_config = base_config.copy()
143143
self.breeder_table_name = 'breeder_meta_data'
144144
self.credentials_table_name = 'credentials'
145+
self.targets_table_name = 'targets'
145146

146147
def _get_db_config(self):
147148
"""Get database config with metadata database name"""
@@ -311,6 +312,95 @@ def delete_credential(self, credential_id):
311312
execute_query(db_config, query)
312313
logger.info(f"Deleted credential catalog entry: {credential_id}")
313314

315+
def create_targets_table(self):
316+
"""Create the targets catalog table"""
317+
db_config = self._get_db_config()
318+
319+
query = f"""
320+
CREATE TABLE IF NOT EXISTS {self.targets_table_name}
321+
(
322+
id uuid PRIMARY KEY,
323+
name VARCHAR(255) UNIQUE NOT NULL,
324+
target_type VARCHAR(50) NOT NULL,
325+
address VARCHAR(255) NOT NULL,
326+
username VARCHAR(255),
327+
credential_id VARCHAR(255),
328+
description TEXT,
329+
allows_downtime BOOLEAN DEFAULT FALSE,
330+
metadata JSONB,
331+
created_at TIMESTAMPTZ DEFAULT NOW(),
332+
last_used_at TIMESTAMPTZ
333+
);
334+
"""
335+
336+
execute_query(db_config, query)
337+
logger.info(f"Ensured targets table exists: {self.targets_table_name}")
338+
339+
def insert_target(self, target_id, name, target_type, address, username=None, credential_id=None, description=None, allows_downtime=False, metadata=None):
340+
"""Insert target catalog entry"""
341+
db_config = self._get_db_config()
342+
metadata_json = json.dumps(metadata) if metadata else 'NULL'
343+
description_escaped = "'" + description.replace("'", "''") + "'" if description else 'NULL'
344+
username_escaped = "'" + username.replace("'", "''") + "'" if username else 'NULL'
345+
credential_id_escaped = "'" + credential_id.replace("'", "''") + "'" if credential_id else 'NULL'
346+
347+
query = f"""
348+
INSERT INTO {self.targets_table_name}
349+
(id, name, target_type, address, username, credential_id, description, allows_downtime, metadata)
350+
VALUES('{target_id}', '{name}', '{target_type}', '{address}', {username_escaped},
351+
{credential_id_escaped}, {description_escaped}, {allows_downtime}, {metadata_json}::jsonb);
352+
"""
353+
354+
execute_query(db_config, query)
355+
logger.info(f"Inserted target catalog entry: {name}")
356+
357+
def fetch_targets_list(self):
358+
"""Fetch list of all targets"""
359+
db_config = self._get_db_config()
360+
361+
query = f"""
362+
SELECT id, name, target_type, address, username, credential_id, description, allows_downtime, created_at, last_used_at
363+
FROM {self.targets_table_name}
364+
ORDER BY created_at DESC;
365+
"""
366+
367+
result = execute_query(db_config, query, with_result=True)
368+
return result if result else []
369+
370+
def fetch_target_by_id(self, target_id):
371+
"""Fetch target by ID"""
372+
db_config = self._get_db_config()
373+
374+
query = f"""
375+
SELECT id, name, target_type, address, username, credential_id, description, allows_downtime, metadata, created_at, last_used_at
376+
FROM {self.targets_table_name}
377+
WHERE id = '{target_id}';
378+
"""
379+
380+
result = execute_query(db_config, query, with_result=True)
381+
return result[0] if result else None
382+
383+
def fetch_target_by_name(self, name):
384+
"""Fetch target by name"""
385+
db_config = self._get_db_config()
386+
387+
query = f"""
388+
SELECT id, name, target_type, address, username, credential_id, description, allows_downtime, metadata, created_at, last_used_at
389+
FROM {self.targets_table_name}
390+
WHERE name = '{name}';
391+
"""
392+
393+
result = execute_query(db_config, query, with_result=True)
394+
return result[0] if result else None
395+
396+
def delete_target(self, target_id):
397+
"""Delete target from catalog"""
398+
db_config = self._get_db_config()
399+
400+
query = f"DELETE FROM {self.targets_table_name} WHERE id = '{target_id}';"
401+
execute_query(db_config, query)
402+
logger.info(f"Deleted target catalog entry: {target_id}")
403+
314404
def update_credential_last_used(self, credential_id):
315405
"""Update the last_used_at timestamp for a credential"""
316406
db_config = self._get_db_config()

0 commit comments

Comments
 (0)