Skip to content

Commit 4492752

Browse files
committed
feat(catalog): add loader for security-evaluations.ndjson
Extends processModelArtifactsBatch() to parse security-evaluations.ndjson files from the existing performance metrics directories, creating artifacts with metricsType: security-metrics. No new config flags or dependencies required. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Paul Boyd <paul@pboyd.io>
1 parent 2a39554 commit 4492752

2 files changed

Lines changed: 649 additions & 4 deletions

File tree

catalog/internal/catalog/modelcatalog/performance_metrics.go

Lines changed: 186 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/golang/glog"
1616
dbmodels "github.com/kubeflow/hub/catalog/internal/catalog/modelcatalog/models"
1717
"github.com/kubeflow/hub/catalog/internal/db/service"
18-
"github.com/kubeflow/hub/internal/platform/apiutils"
1918
models "github.com/kubeflow/hub/internal/platform/db/entity"
2019
)
2120

@@ -127,6 +126,41 @@ func (pr *performanceRecord) UnmarshalJSON(data []byte) error {
127126
return nil
128127
}
129128

129+
// securityEvaluationRecord represents a single security evaluation result from security-evaluations.ndjson
130+
type securityEvaluationRecord struct {
131+
// Core fields needed to associate security data with model
132+
ID string `json:"id"`
133+
ModelID string `json:"model_id"`
134+
135+
// CustomProperties captures remaining fields dynamically
136+
CustomProperties map[string]any `json:"-"`
137+
}
138+
139+
// UnmarshalJSON implements custom JSON unmarshaling to capture all undefined fields as CustomProperties
140+
func (sr *securityEvaluationRecord) UnmarshalJSON(data []byte) error {
141+
var raw map[string]any
142+
decoder := json.NewDecoder(bytes.NewReader(data))
143+
decoder.UseNumber()
144+
if err := decoder.Decode(&raw); err != nil {
145+
return err
146+
}
147+
148+
if id, ok := raw["id"].(string); ok {
149+
sr.ID = id
150+
}
151+
if modelID, ok := raw["model_id"].(string); ok {
152+
sr.ModelID = modelID
153+
}
154+
155+
if sr.CustomProperties == nil {
156+
sr.CustomProperties = make(map[string]any)
157+
}
158+
159+
maps.Copy(sr.CustomProperties, raw)
160+
161+
return nil
162+
}
163+
130164
type PerformanceMetricsLoader struct {
131165
path []string
132166
modelRepo dbmodels.CatalogModelRepository
@@ -324,6 +358,7 @@ func processModelArtifactsBatch(dirPath string, modelID int32, modelName string,
324358
// Parse all metrics files
325359
var evaluationRecords []evaluationRecord
326360
var performanceRecords []performanceRecord
361+
var securityRecords []securityEvaluationRecord
327362

328363
// Parse evaluation metrics if file exists
329364
evaluationsPath := filepath.Join(dirPath, "evaluations.ndjson")
@@ -347,7 +382,18 @@ func processModelArtifactsBatch(dirPath string, modelID int32, modelName string,
347382
}
348383
}
349384

350-
totalRecords := len(evaluationRecords) + len(performanceRecords)
385+
// Parse security evaluation metrics if file exists
386+
securityPath := filepath.Join(dirPath, "security-evaluations.ndjson")
387+
if _, err := os.Stat(securityPath); err == nil {
388+
records, err := parseSecurityEvaluationFile(securityPath)
389+
if err != nil {
390+
glog.Errorf("Failed to parse security evaluations file for %s: %v", modelName, err)
391+
} else {
392+
securityRecords = records
393+
}
394+
}
395+
396+
totalRecords := len(evaluationRecords) + len(performanceRecords) + len(securityRecords)
351397
if totalRecords == 0 {
352398
return 0, nil
353399
}
@@ -393,6 +439,22 @@ func processModelArtifactsBatch(dirPath string, modelID int32, modelName string,
393439
}
394440
}
395441

442+
// Check security evaluation artifacts; deduplicate within the file before the DB check
443+
seenSecurityIDs := make(map[string]bool, len(securityRecords))
444+
for _, secRecord := range securityRecords {
445+
if seenSecurityIDs[secRecord.ID] {
446+
glog.Warningf("Duplicate security artifact ID %s in file, skipping", secRecord.ID)
447+
continue
448+
}
449+
seenSecurityIDs[secRecord.ID] = true
450+
if !existingArtifactsMap[secRecord.ID] {
451+
artifact := createSecurityArtifact(secRecord, modelID, metricsArtifactTypeID, nil, nil)
452+
artifactsToInsert = append(artifactsToInsert, artifact)
453+
} else {
454+
glog.V(2).Infof("Security artifact %s already exists, skipping", secRecord.ID)
455+
}
456+
}
457+
396458
if len(artifactsToInsert) == 0 {
397459
glog.V(2).Infof("All artifacts already exist for model %s, nothing to insert", modelName)
398460
return 0, nil
@@ -578,7 +640,7 @@ func createPerformanceArtifact(perfRecord performanceRecord, modelID int32, type
578640
}
579641
}
580642
if createTime == nil {
581-
createTime = apiutils.Of(time.Now().UnixMilli())
643+
createTime = new(time.Now().UnixMilli())
582644
}
583645

584646
if updatedAtNum, ok := perfRecord.CustomProperties["updated_at"].(json.Number); ok {
@@ -590,7 +652,7 @@ func createPerformanceArtifact(perfRecord performanceRecord, modelID int32, type
590652
}
591653
}
592654
if updateTime == nil {
593-
updateTime = apiutils.Of(time.Now().UnixMilli())
655+
updateTime = new(time.Now().UnixMilli())
594656
}
595657
delete(perfRecord.CustomProperties, "updated_at")
596658
delete(perfRecord.CustomProperties, "created_at")
@@ -655,6 +717,126 @@ func createPerformanceArtifact(perfRecord performanceRecord, modelID int32, type
655717
return metricsArtifact
656718
}
657719

720+
// parseSecurityEvaluationFile reads and parses a security-evaluations.ndjson file
721+
func parseSecurityEvaluationFile(filePath string) ([]securityEvaluationRecord, error) {
722+
file, err := os.Open(filePath)
723+
if err != nil {
724+
return nil, fmt.Errorf("failed to open security evaluation file %s: %v", filePath, err)
725+
}
726+
defer file.Close()
727+
728+
scanner := bufio.NewScanner(file)
729+
securityRecords := []securityEvaluationRecord{}
730+
731+
for scanner.Scan() {
732+
line := scanner.Text()
733+
if strings.TrimSpace(line) == "" {
734+
continue
735+
}
736+
737+
var secRecord securityEvaluationRecord
738+
if err := json.Unmarshal([]byte(line), &secRecord); err != nil {
739+
glog.Errorf("Failed to parse security evaluation record: %v", err)
740+
continue
741+
}
742+
743+
securityRecords = append(securityRecords, secRecord)
744+
}
745+
746+
if err := scanner.Err(); err != nil {
747+
return nil, fmt.Errorf("error reading security evaluation file: %v", err)
748+
}
749+
750+
return securityRecords, nil
751+
}
752+
753+
// createSecurityArtifact creates a metrics artifact from a security evaluation record
754+
func createSecurityArtifact(secRecord securityEvaluationRecord, modelID int32, typeID int32, existingID *int32, existingCreateTime *int64) *dbmodels.CatalogMetricsArtifactImpl {
755+
artifactName := fmt.Sprintf("security-%s", secRecord.ID)
756+
757+
createTime := existingCreateTime
758+
var updateTime *int64
759+
760+
if existingCreateTime == nil {
761+
if createdAtNum, ok := secRecord.CustomProperties["created_at"].(json.Number); ok {
762+
createdAt, err := createdAtNum.Int64()
763+
if err == nil {
764+
createTime = &createdAt
765+
} else {
766+
glog.Warningf("%s: invalid created_at value: %v", artifactName, err)
767+
}
768+
}
769+
}
770+
if createTime == nil {
771+
createTime = new(time.Now().UnixMilli())
772+
}
773+
774+
if updatedAtNum, ok := secRecord.CustomProperties["updated_at"].(json.Number); ok {
775+
updatedAt, err := updatedAtNum.Int64()
776+
if err == nil {
777+
updateTime = &updatedAt
778+
} else {
779+
glog.Warningf("%s: invalid updated_at value: %v", artifactName, err)
780+
}
781+
}
782+
if updateTime == nil {
783+
updateTime = new(time.Now().UnixMilli())
784+
}
785+
delete(secRecord.CustomProperties, "updated_at")
786+
delete(secRecord.CustomProperties, "created_at")
787+
788+
properties := []models.Properties{}
789+
customProperties := []models.Properties{}
790+
791+
for key, value := range secRecord.CustomProperties {
792+
prop := models.Properties{Name: key}
793+
794+
switch v := value.(type) {
795+
case string:
796+
prop.StringValue = &v
797+
case float64:
798+
prop.DoubleValue = &v
799+
case int64:
800+
prop.SetInt64Value(v)
801+
case int:
802+
intVal := int32(v)
803+
prop.IntValue = &intVal
804+
case bool:
805+
prop.BoolValue = &v
806+
case json.Number:
807+
if n, err := v.Int64(); err == nil {
808+
prop.SetInt64Value(n)
809+
} else if f, err := v.Float64(); err == nil {
810+
prop.DoubleValue = &f
811+
} else {
812+
strVal := v.String()
813+
prop.StringValue = &strVal
814+
}
815+
default:
816+
strVal := fmt.Sprintf("%v", v)
817+
prop.StringValue = &strVal
818+
}
819+
820+
customProperties = append(customProperties, prop)
821+
}
822+
823+
metricsArtifact := &dbmodels.CatalogMetricsArtifactImpl{
824+
ID: existingID,
825+
TypeID: &typeID,
826+
Attributes: &dbmodels.CatalogMetricsArtifactAttributes{
827+
Name: &artifactName,
828+
ExternalID: &secRecord.ID,
829+
CreateTimeSinceEpoch: createTime,
830+
LastUpdateTimeSinceEpoch: updateTime,
831+
MetricsType: dbmodels.MetricsTypeSecurityMetrics,
832+
},
833+
Properties: &properties,
834+
CustomProperties: &customProperties,
835+
}
836+
837+
return metricsArtifact
838+
}
839+
658840
// enrichCatalogModelFromMetadata updates CatalogModel with additional fields from metadata.json
659841
func enrichCatalogModelFromMetadata(existingModel dbmodels.CatalogModel, metadata metadataJSON, modelRepo dbmodels.CatalogModelRepository) error {
660842
// Build custom properties to add/update

0 commit comments

Comments
 (0)