Skip to content

Commit a326fc9

Browse files
committed
HYPERFLEET-864 - fix: handle string manifests in resolveGVK for discovery
resolveGVK returned empty GVK for string manifests (from ref files), causing discovery to fail with 'Object Kind is missing'. Now extracts apiVersion and kind by line scanning, which handles manifests with Go template directives that break full YAML parsing.
1 parent 75e8fbd commit a326fc9

4 files changed

Lines changed: 136 additions & 10 deletions

File tree

internal/configloader/validator.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,11 @@ func (v *TaskConfigValidator) validateTemplateVariables() {
370370
// Validate resource manifests and transport config templates
371371
for i, resource := range v.config.Resources {
372372
resourcePath := fmt.Sprintf("%s[%d]", FieldResources, i)
373-
if manifest, ok := resource.Manifest.(map[string]interface{}); ok {
373+
switch manifest := resource.Manifest.(type) {
374+
case map[string]interface{}:
374375
v.validateTemplateMap(manifest, resourcePath+"."+FieldManifest)
376+
case string:
377+
v.validateTemplateString(manifest, resourcePath+"."+FieldManifest)
375378
}
376379
// NOTE: For maestro transport, we skip template variable validation for manifest content.
377380
// ManifestWork templates may use variables provided at runtime by the framework

internal/configloader/validator_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ func TestValidateK8sManifests(t *testing.T) {
354354
// String manifests contain raw Go templates that can't be validated
355355
// until template rendering at execution time
356356
cfg := baseTaskConfig()
357+
cfg.Params = []Parameter{
358+
{Name: "name", Source: "event.name", Type: "string"},
359+
{Name: "addLabels", Source: "event.addLabels", Type: "bool"},
360+
{Name: "appName", Source: "event.appName", Type: "string"},
361+
}
357362
cfg.Resources = []Resource{{
358363
Name: "testResource",
359364
Manifest: `apiVersion: v1

internal/executor/resource_executor.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strings"
78

89
"github.com/mitchellh/copystructure"
910
"github.com/openshift-hyperfleet/hyperfleet-adapter/internal/configloader"
@@ -261,6 +262,10 @@ func (re *ResourceExecutor) renderStringManifest(
261262
manifestStr string,
262263
params map[string]interface{},
263264
) ([]byte, error) {
265+
if strings.TrimSpace(manifestStr) == "" {
266+
return nil, fmt.Errorf("empty manifest: string manifest cannot be empty")
267+
}
268+
264269
// Render the entire string as a Go template
265270
rendered, err := renderTemplate(manifestStr, params)
266271
if err != nil {
@@ -269,8 +274,8 @@ func (re *ResourceExecutor) renderStringManifest(
269274

270275
// Parse the rendered string as YAML
271276
var manifestData map[string]interface{}
272-
if err := yaml.Unmarshal([]byte(rendered), &manifestData); err != nil {
273-
return nil, fmt.Errorf("failed to parse rendered manifest as YAML: %w", err)
277+
if unmarshalErr := yaml.Unmarshal([]byte(rendered), &manifestData); unmarshalErr != nil {
278+
return nil, fmt.Errorf("failed to parse rendered manifest as YAML: %w", unmarshalErr)
274279
}
275280

276281
// Marshal to JSON bytes
@@ -451,10 +456,20 @@ func (re *ResourceExecutor) buildNestedDiscoveryConfig(
451456
// resolveGVK extracts the GVK from the resource's manifest.
452457
// Works for both K8s resources and ManifestWorks since both have apiVersion and kind.
453458
func (re *ResourceExecutor) resolveGVK(resource configloader.Resource) schema.GroupVersionKind {
454-
manifestData, ok := resource.Manifest.(map[string]interface{})
455-
if !ok {
459+
var manifestData map[string]interface{}
460+
461+
switch m := resource.Manifest.(type) {
462+
case map[string]interface{}:
463+
manifestData = m
464+
case string:
465+
// String manifests may contain Go template directives ({{ if }}, {{ range }})
466+
// that make them invalid YAML. Extract apiVersion and kind by scanning lines
467+
// instead of full YAML parsing.
468+
return extractGVKFromString(m)
469+
default:
456470
return schema.GroupVersionKind{}
457471
}
472+
458473
apiVersion, ok1 := manifestData["apiVersion"].(string)
459474
kind, ok2 := manifestData["kind"].(string)
460475
if !ok1 || !ok2 {
@@ -467,6 +482,34 @@ func (re *ResourceExecutor) resolveGVK(resource configloader.Resource) schema.Gr
467482
return gv.WithKind(kind)
468483
}
469484

485+
// extractGVKFromString extracts apiVersion and kind from a YAML string
486+
// by scanning lines. This handles manifests with Go template directives
487+
// that would fail full YAML parsing.
488+
func extractGVKFromString(manifest string) schema.GroupVersionKind {
489+
var apiVersion, kind string
490+
for _, line := range strings.Split(manifest, "\n") {
491+
trimmed := strings.TrimSpace(line)
492+
if strings.HasPrefix(trimmed, "apiVersion:") {
493+
apiVersion = strings.TrimSpace(strings.TrimPrefix(trimmed, "apiVersion:"))
494+
apiVersion = strings.Trim(apiVersion, "\"'")
495+
} else if strings.HasPrefix(trimmed, "kind:") {
496+
kind = strings.TrimSpace(strings.TrimPrefix(trimmed, "kind:"))
497+
kind = strings.Trim(kind, "\"'")
498+
}
499+
if apiVersion != "" && kind != "" {
500+
break
501+
}
502+
}
503+
if apiVersion == "" || kind == "" {
504+
return schema.GroupVersionKind{}
505+
}
506+
gv, err := schema.ParseGroupVersion(apiVersion)
507+
if err != nil {
508+
return schema.GroupVersionKind{}
509+
}
510+
return gv.WithKind(kind)
511+
}
512+
470513
// convertToStringKeyMap converts map[interface{}]interface{} to map[string]interface{}
471514
func convertToStringKeyMap(m map[interface{}]interface{}) map[string]interface{} {
472515
result := make(map[string]interface{})

internal/executor/resource_executor_test.go

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -620,11 +620,9 @@ data:
620620
execCtx := NewExecutionContext(context.Background(), nil, nil)
621621
execCtx.Params = map[string]interface{}{}
622622

623-
// Empty YAML produces nil map, which marshals to JSON "null"
624-
// The executor will fail downstream when trying to use the null manifest
625-
data, err := re.renderToBytes(context.Background(), resource, execCtx)
626-
require.NoError(t, err) // renderToBytes succeeds — error happens at apply time
627-
assert.Equal(t, "null", string(data))
623+
_, err := re.renderToBytes(context.Background(), resource, execCtx)
624+
require.Error(t, err)
625+
assert.Contains(t, err.Error(), "empty manifest")
628626
})
629627

630628
t.Run("template rendering produces invalid YAML", func(t *testing.T) {
@@ -768,3 +766,80 @@ data:
768766
assert.True(t, found)
769767
assert.Equal(t, "cluster-1", data)
770768
}
769+
770+
func TestResolveGVK_StringManifest(t *testing.T) {
771+
re := &ResourceExecutor{}
772+
773+
tests := []struct {
774+
name string
775+
manifest interface{}
776+
wantGroup string
777+
wantVersion string
778+
wantKind string
779+
wantEmpty bool
780+
}{
781+
{
782+
name: "map manifest",
783+
manifest: map[string]interface{}{
784+
"apiVersion": "v1",
785+
"kind": "ConfigMap",
786+
},
787+
wantVersion: "v1",
788+
wantKind: "ConfigMap",
789+
},
790+
{
791+
name: "string manifest with Go templates",
792+
manifest: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: \"{{ .clusterId }}\"\n",
793+
wantVersion: "v1",
794+
wantKind: "ConfigMap",
795+
},
796+
{
797+
name: "string manifest with structural Go template directives",
798+
manifest: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n" +
799+
" name: \"test-{{ .clusterId }}\"\n labels:\n app: test\n" +
800+
"{{ if .testRunId }}\n run-id: \"{{ .testRunId }}\"\n{{ end }}\n" +
801+
"data:\n key: value\n",
802+
wantVersion: "v1",
803+
wantKind: "ConfigMap",
804+
},
805+
{
806+
name: "string manifest with apps/v1",
807+
manifest: "apiVersion: apps/v1\nkind: Deployment\n",
808+
wantGroup: "apps",
809+
wantVersion: "v1",
810+
wantKind: "Deployment",
811+
},
812+
{
813+
name: "nil manifest",
814+
manifest: nil,
815+
wantEmpty: true,
816+
},
817+
{
818+
name: "invalid string YAML",
819+
manifest: "not: valid: yaml: {{{}",
820+
wantEmpty: true,
821+
},
822+
{
823+
name: "string manifest missing kind",
824+
manifest: "apiVersion: v1\nmetadata:\n name: test\n",
825+
wantEmpty: true,
826+
},
827+
}
828+
829+
for _, tt := range tests {
830+
t.Run(tt.name, func(t *testing.T) {
831+
resource := configloader.Resource{
832+
Manifest: tt.manifest,
833+
}
834+
gvk := re.resolveGVK(resource)
835+
836+
if tt.wantEmpty {
837+
assert.True(t, gvk.Empty(), "expected empty GVK")
838+
} else {
839+
assert.Equal(t, tt.wantGroup, gvk.Group)
840+
assert.Equal(t, tt.wantVersion, gvk.Version)
841+
assert.Equal(t, tt.wantKind, gvk.Kind)
842+
}
843+
})
844+
}
845+
}

0 commit comments

Comments
 (0)