Skip to content

Commit e0ec249

Browse files
authored
Merge branch 'master' into peeter/fix-contributing-page
2 parents 703fddd + 7c719b8 commit e0ec249

9 files changed

Lines changed: 372 additions & 12 deletions

File tree

flytectl/cmd/config/subcommand/matchable_attr_file_config_utils.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func DumpTaskResourceAttr(matchableAttrConfig interface{}, fileName string) erro
5252
if err := WriteConfigToFile(matchableAttrConfig, fileName); err != nil {
5353
return fmt.Errorf("error dumping in file due to %v", err)
5454
}
55-
fmt.Printf("wrote the config to file %v", fileName)
55+
fmt.Printf("wrote the config to file %v\n", fileName)
5656
} else {
5757
fmt.Printf("%v", String(matchableAttrConfig))
5858
}

flytectl/cmd/get/matchable_workflow_execution_config.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ import (
88
sconfig "github.com/flyteorg/flyte/flytectl/cmd/config/subcommand"
99
"github.com/flyteorg/flyte/flytectl/cmd/config/subcommand/workflowexecutionconfig"
1010
cmdCore "github.com/flyteorg/flyte/flytectl/cmd/core"
11+
"github.com/flyteorg/flyte/flytectl/pkg/ext"
1112
"github.com/flyteorg/flyte/flyteidl/gen/pb-go/flyteidl/admin"
1213
"github.com/flyteorg/flyte/flyteidl/gen/pb-go/flyteidl/core"
13-
"google.golang.org/grpc/codes"
14-
"google.golang.org/grpc/status"
1514
)
1615

1716
const (
@@ -141,7 +140,7 @@ func getWorkflowExecutionConfigFunc(ctx context.Context, args []string, cmdCtx c
141140
// Updates the workflowExecutionConfigFileConfig with the fetched matchable attribute
142141
if err := FetchAndUnDecorateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminFetcherExt(),
143142
&workflowExecutionConfigFileConfig, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG); err != nil {
144-
if grpcError := status.Code(err); grpcError == codes.NotFound && workflowexecutionconfig.DefaultFetchConfig.Gen {
143+
if ext.IsNotFoundError(err) && workflowexecutionconfig.DefaultFetchConfig.Gen {
145144
fmt.Println("Generating a sample workflow execution config file")
146145
workflowExecutionConfigFileConfig = getSampleWorkflowExecutionFileConfig(project, domain, workflowName)
147146
} else {

flytepropeller/pkg/controller/nodes/task/k8s/plugin_manager.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,14 @@ func (e *PluginManager) launchResource(ctx context.Context, tCtx pluginsCore.Tas
262262
} else if k8serrors.IsForbidden(err) {
263263
return pluginsCore.DoTransition(pluginsCore.PhaseInfoRetryableFailure("RuntimeFailure", err.Error(), nil)), nil
264264
} else if k8serrors.IsBadRequest(err) || k8serrors.IsInvalid(err) {
265+
// BadRequest (HTTP 400) and Invalid (HTTP 422) errors are intrinsic
266+
// to the request payload and not transient. The most common source
267+
// is a validating admission webhook rejecting the pod spec; retrying
268+
// with the same input will produce the same rejection. Treat as a
269+
// permanent failure so the validation error surfaces to the user
270+
// instead of exhausting the workflow's retry budget.
265271
logger.Errorf(ctx, "Badly formatted resource for plugin [%s], err %s", e.id, err)
266-
// return pluginsCore.DoTransition(pluginsCore.PhaseInfoFailure("BadTaskFormat", err.Error(), nil)), nil
272+
return pluginsCore.DoTransition(pluginsCore.PhaseInfoFailure("BadTaskFormat", err.Error(), nil)), nil
267273
} else if k8serrors.IsRequestEntityTooLargeError(err) {
268274
logger.Errorf(ctx, "Badly formatted resource for plugin [%s], err %s", e.id, err)
269275
return pluginsCore.DoTransition(pluginsCore.PhaseInfoFailure("EntityTooLarge", err.Error(), nil)), nil

flytepropeller/pkg/controller/nodes/task/k8s/plugin_manager_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,75 @@ func TestK8sTaskExecutor_Handle_LaunchResource(t *testing.T) {
408408
assert.True(t, k8serrors.IsNotFound(err))
409409
})
410410

411+
t.Run("jobBadRequest", func(t *testing.T) {
412+
// BadRequest (HTTP 400) errors — typically from a validating admission
413+
// webhook — are intrinsic to the request payload and not transient.
414+
// Retrying with the same input will produce the same rejection.
415+
// They should be treated as a permanent failure (PhasePermanentFailure)
416+
// rather than the default retryable system error, so workflows surface
417+
// the validation error to the user instead of exhausting their retry
418+
// budget. See https://github.com/flyteorg/flyte/issues/6531.
419+
tctx := getMockTaskContext(PluginPhaseNotStarted, PluginPhaseNotStarted)
420+
mockResourceHandler := &pluginsk8sMock.Plugin{}
421+
mockResourceHandler.EXPECT().GetProperties().Return(k8s.PluginProperties{})
422+
mockResourceHandler.EXPECT().BuildResource(mock.Anything, mock.Anything).Return(&v1.Pod{}, nil)
423+
fakeClient := extendedFakeClient{
424+
Client: fake.NewClientBuilder().WithRuntimeObjects().Build(),
425+
CreateError: k8serrors.NewBadRequest("admission webhook \"deny.example.com\" denied the request: invalid pod spec"),
426+
}
427+
mockClientset := k8sfake.NewSimpleClientset()
428+
429+
pluginManager, err := NewPluginManager(ctx, dummySetupContext(fakeClient), k8s.PluginEntry{
430+
ID: "x",
431+
ResourceToWatch: &v1.Pod{},
432+
Plugin: mockResourceHandler,
433+
}, NewResourceMonitorIndex(), mockClientset)
434+
assert.NoError(t, err)
435+
436+
transition, err := pluginManager.Handle(ctx, tctx)
437+
assert.NoError(t, err)
438+
assert.NotNil(t, transition)
439+
transitionInfo := transition.Info()
440+
assert.NotNil(t, transitionInfo)
441+
assert.Equal(t, pluginsCore.PhasePermanentFailure, transitionInfo.Phase())
442+
assert.Equal(t, "BadTaskFormat", transitionInfo.Err().GetCode())
443+
})
444+
445+
t.Run("jobInvalid", func(t *testing.T) {
446+
// Invalid (HTTP 422) errors indicate the request was well-formed but
447+
// the object failed validation (e.g. an invalid field value). Like
448+
// BadRequest, this is intrinsic to the payload and not transient, so
449+
// it should be a permanent failure.
450+
tctx := getMockTaskContext(PluginPhaseNotStarted, PluginPhaseNotStarted)
451+
mockResourceHandler := &pluginsk8sMock.Plugin{}
452+
mockResourceHandler.EXPECT().GetProperties().Return(k8s.PluginProperties{})
453+
mockResourceHandler.EXPECT().BuildResource(mock.Anything, mock.Anything).Return(&v1.Pod{}, nil)
454+
fakeClient := extendedFakeClient{
455+
Client: fake.NewClientBuilder().WithRuntimeObjects().Build(),
456+
CreateError: k8serrors.NewInvalid(
457+
schema.GroupKind{Group: "", Kind: "Pod"},
458+
"test-pod",
459+
nil,
460+
),
461+
}
462+
mockClientset := k8sfake.NewSimpleClientset()
463+
464+
pluginManager, err := NewPluginManager(ctx, dummySetupContext(fakeClient), k8s.PluginEntry{
465+
ID: "x",
466+
ResourceToWatch: &v1.Pod{},
467+
Plugin: mockResourceHandler,
468+
}, NewResourceMonitorIndex(), mockClientset)
469+
assert.NoError(t, err)
470+
471+
transition, err := pluginManager.Handle(ctx, tctx)
472+
assert.NoError(t, err)
473+
assert.NotNil(t, transition)
474+
transitionInfo := transition.Info()
475+
assert.NotNil(t, transitionInfo)
476+
assert.Equal(t, pluginsCore.PhasePermanentFailure, transitionInfo.Phase())
477+
assert.Equal(t, "BadTaskFormat", transitionInfo.Err().GetCode())
478+
})
479+
411480
t.Run("Insufficient resource blocking pod creation for the first time", func(t *testing.T) {
412481
tctx := getMockTaskContext(PluginPhaseNotStarted, PluginPhaseNotStarted)
413482
var tmpl *core.TaskTemplate

flytestdlib/config/tests/accessor_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ type ComplexType struct {
8989

9090
type ComplexTypeArray []ComplexType
9191

92+
type Plugin struct {
93+
Name string `json:"name"`
94+
Annotations map[string]string `json:"annotations"`
95+
}
96+
97+
type DottedKeysConfig struct {
98+
Annotations map[string]string `json:"annotations"`
99+
Plugins []Plugin `json:"plugins"`
100+
}
101+
92102
type ConfigWithLists struct {
93103
ListOfStuff []ComplexType `json:"list"`
94104
StringValue string `json:"string-val"`
@@ -403,6 +413,27 @@ func TestAccessor_UpdateConfig(t *testing.T) {
403413
assert.Equal(t, "xyz1", r.ItemsMap["itemA"]["itemAb"].ID)
404414
assert.Equal(t, "xyz2", r.ItemsMap["itemB"]["itemBb"].ID)
405415
})
416+
417+
t.Run("DottedKeysEndToEnd", func(t *testing.T) {
418+
root := config.NewRootSection()
419+
_, err := root.RegisterSection("dotted-keys", &DottedKeysConfig{})
420+
assert.NoError(t, err)
421+
422+
v := provider(config.Options{
423+
SearchPaths: []string{filepath.Join("testdata", "dotted_keys_config.yaml")},
424+
RootSection: root,
425+
})
426+
427+
assert.NoError(t, v.UpdateConfig(context.TODO()))
428+
r := root.GetSection("dotted-keys").GetConfig().(*DottedKeysConfig)
429+
430+
assert.Equal(t, "false", r.Annotations["cluster-autoscaler.kubernetes.io/safe-to-evict"])
431+
assert.Equal(t, "true", r.Annotations["sidecar.istio.io/inject"])
432+
433+
assert.Len(t, r.Plugins, 1)
434+
assert.Equal(t, "KeepMyCase", r.Plugins[0].Name)
435+
assert.Equal(t, "/etc/plugin", r.Plugins[0].Annotations["config.path"])
436+
})
406437
})
407438

408439
t.Run(fmt.Sprintf("[%v] Override in Env Var", provider(config.Options{}).ID()), func(t *testing.T) {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
dotted-keys:
2+
annotations:
3+
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
4+
sidecar.istio.io/inject: "true"
5+
plugins:
6+
- name: KeepMyCase
7+
annotations:
8+
config.path: /etc/plugin

flytestdlib/config/viper/viper.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ func (v viperAccessor) parseViperConfig(root config.Section) error {
289289
}
290290

291291
restoreCaseSensitiveArrayKeys(settings, rawSettings)
292+
restoreDottedMapKeys(settings, rawSettings)
292293
}
293294

294295
return v.parseViperConfigRecursive(root, settings)
@@ -324,6 +325,84 @@ func restoreCaseSensitiveArrayKeys(viperData, rawData map[string]interface{}) {
324325
}
325326
}
326327

328+
// restoreDottedMapKeys undoes viper's splitting of dotted YAML keys.
329+
//
330+
// - Viper hard-codes "." as its key delimiter and splits every key on it,
331+
// so a YAML leaf key like "test.annotation" arrives in viperData as the
332+
// nested map {"test": {"annotation": ...}}.
333+
// - rawData is the authoritative source for what the user wrote.
334+
// - We walk rawData, detect dotted keys, drop the nested skeleton from
335+
// viperData, and reinsert the value under its original dotted key.
336+
func restoreDottedMapKeys(viperData, rawData map[string]interface{}) {
337+
for rawKey, rawVal := range rawData {
338+
if strings.Contains(rawKey, keyDelim) {
339+
// Drop the nested skeleton viper built from this dotted key, then
340+
// reinsert the raw value under the original key. Lowercase the
341+
// path because viper lowercases all keys.
342+
pruneSplitPath(viperData, strings.Split(strings.ToLower(rawKey), keyDelim))
343+
viperData[rawKey] = rawVal
344+
continue
345+
}
346+
347+
// rawVal may itself be a map containing dotted keys deeper down
348+
// (e.g. annotations: {test.annotation: ...}); recurse to fix them.
349+
viperKey, ok := findViperKey(viperData, rawKey)
350+
if !ok {
351+
continue
352+
}
353+
if rawMap, ok := rawVal.(map[string]interface{}); ok {
354+
if viperMap, ok := viperData[viperKey].(map[string]interface{}); ok {
355+
restoreDottedMapKeys(viperMap, rawMap)
356+
}
357+
}
358+
}
359+
}
360+
361+
// findViperKey returns the key in viperData that case-insensitively matches
362+
// rawKey. Viper lowercases all keys internally, but raw YAML preserves the
363+
// original casing, so we must scan.
364+
func findViperKey(viperData map[string]interface{}, rawKey string) (string, bool) {
365+
for k := range viperData {
366+
if strings.EqualFold(k, rawKey) {
367+
return k, true
368+
}
369+
}
370+
return "", false
371+
}
372+
373+
// pruneSplitPath removes the nested-map skeleton viper created when it split a
374+
// dotted key on ".". It walks the path top-down, deleting only nodes that
375+
// have no remaining children, so sibling keys that share a prefix are left
376+
// intact (e.g. removing "a.b.c" must not drop "a.b.d").
377+
func pruneSplitPath(m map[string]interface{}, parts []string) {
378+
if len(parts) == 0 {
379+
return
380+
}
381+
head := parts[0]
382+
child, ok := m[head]
383+
if !ok {
384+
// head not in the map m, directly return.
385+
return
386+
}
387+
if len(parts) == 1 {
388+
// Leaf of the split path — this is the value viper produced from the
389+
// dotted key. Drop it; the caller will reinsert the raw value.
390+
delete(m, head)
391+
return
392+
}
393+
childMap, ok := child.(map[string]interface{})
394+
if !ok {
395+
// Path diverges from what viper would have produced; leave alone.
396+
return
397+
}
398+
pruneSplitPath(childMap, parts[1:])
399+
// Drop the parent if recursion emptied it — keeps the cleanup tight so we
400+
// don't leave behind empty intermediate maps from the split.
401+
if len(childMap) == 0 {
402+
delete(m, head)
403+
}
404+
}
405+
327406
func (v viperAccessor) parseViperConfigRecursive(root config.Section, settings interface{}) error {
328407
errs := stdLibErrs.ErrorCollection{}
329408
var mine interface{}

0 commit comments

Comments
 (0)