-
Notifications
You must be signed in to change notification settings - Fork 23
[eno] Add default infrastructure resource ordering #583
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ruinan-liu
merged 7 commits into
main
from
users/ruinanliu/resource-slice-default-ordering
Apr 28, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
45a6977
Initial commit for adding default readiness groupos
ruinan-liu 78816c2
Fixing the unit tests
ruinan-liu 1bf7305
Adding e2e test
ruinan-liu 721e982
Merge branch 'main' of https://github.com/Azure/eno into users/ruinan…
ruinan-liu 0d4c2ab
Fixing comments
ruinan-liu 1993eca
Resolving resulting comments
ruinan-liu 2aca822
Resolving comments
ruinan-liu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| package e2e | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
|
|
||
| flow "github.com/Azure/go-workflow" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| appsv1 "k8s.io/api/apps/v1" | ||
| corev1 "k8s.io/api/core/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/types" | ||
| "k8s.io/apimachinery/pkg/util/wait" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
|
|
||
| apiv1 "github.com/Azure/eno/api/v1" | ||
| fw "github.com/Azure/eno/e2e/framework" | ||
| ) | ||
|
|
||
| // TestResourceDependencyOrdering validates that Eno correctly handles the | ||
| // dependency ordering between a Secret and a Deployment that mounts it, | ||
| // WITHOUT explicit eno.azure.io/readiness-group annotations. | ||
| // | ||
| // The synthesizer outputs both a Secret and a Deployment (which mounts | ||
| // the Secret as a volume). The test verifies: | ||
| // - Both resources are created successfully | ||
| // - The Deployment becomes available (at least one ready pod) | ||
| // - Pods have zero restarts (no CrashLoopBackOff) | ||
| // - No container errors (waiting/terminated with failure) | ||
| func TestResourceDependencyOrdering(t *testing.T) { | ||
| t.Parallel() | ||
| ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) | ||
| defer cancel() | ||
|
|
||
| cli := fw.NewClient(t) | ||
|
|
||
| synthName := fw.UniqueName("dep-order-synth") | ||
| compName := fw.UniqueName("dep-order-comp") | ||
| secretName := fw.UniqueName("dep-order-secret") | ||
| deployName := fw.UniqueName("dep-order-deploy") | ||
|
|
||
| // Secret with NO readiness-group annotation. | ||
| secret := &corev1.Secret{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "default"}, | ||
| StringData: map[string]string{"token": "test-value-123"}, | ||
| } | ||
|
|
||
| // Deployment that mounts the Secret as a volume — NO readiness-group annotation. | ||
| replicas := int32(1) | ||
| deploy := &appsv1.Deployment{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: deployName, Namespace: "default"}, | ||
| Spec: appsv1.DeploymentSpec{ | ||
| Replicas: &replicas, | ||
| Selector: &metav1.LabelSelector{ | ||
| MatchLabels: map[string]string{"app": deployName}, | ||
| }, | ||
| Template: corev1.PodTemplateSpec{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Labels: map[string]string{"app": deployName}, | ||
| }, | ||
| Spec: corev1.PodSpec{ | ||
| Containers: []corev1.Container{{ | ||
| Name: "test", | ||
| Image: "docker.io/busybox:1.36.1", | ||
| Command: []string{"sh", "-c", "cat /etc/secret-vol/token && sleep 3600"}, | ||
| VolumeMounts: []corev1.VolumeMount{{ | ||
| Name: "secret-vol", | ||
| MountPath: "/etc/secret-vol", | ||
| ReadOnly: true, | ||
| }}, | ||
| }}, | ||
| Volumes: []corev1.Volume{{ | ||
| Name: "secret-vol", | ||
| VolumeSource: corev1.VolumeSource{ | ||
| Secret: &corev1.SecretVolumeSource{ | ||
| SecretName: secretName, | ||
| }, | ||
| }, | ||
| }}, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| // Synthesizer outputs both Secret and Deployment. | ||
| // Eno should automatically resolve the dependency ordering. | ||
| synth := fw.NewMinimalSynthesizer(synthName, fw.WithCommand(fw.ToCommand(secret, deploy))) | ||
| comp := fw.NewComposition(compName, "default", | ||
| fw.WithSynthesizerRefs(apiv1.SynthesizerRef{Name: synthName})) | ||
| compKey := types.NamespacedName{Name: compName, Namespace: "default"} | ||
|
|
||
| // --- Workflow steps --- | ||
|
|
||
| createSynthesizer := fw.CreateStep(t, "createSynthesizer", cli, synth) | ||
|
|
||
| createComposition := fw.CreateStep(t, "createComposition", cli, comp) | ||
|
|
||
| waitCompositionReady := flow.Func("waitCompositionReady", func(ctx context.Context) error { | ||
| fw.WaitForCompositionReady(t, ctx, cli, compKey, 4*time.Minute) | ||
| t.Log("composition is Ready") | ||
| return nil | ||
| }) | ||
|
|
||
| verifySecret := flow.Func("verifySecret", func(ctx context.Context) error { | ||
| s := &corev1.Secret{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "default"}, | ||
| } | ||
| fw.WaitForResourceExists(t, ctx, cli, s, 30*time.Second) | ||
| assert.Equal(t, "test-value-123", string(s.Data["token"]), | ||
| "secret should contain expected data") | ||
| t.Logf("secret %s exists with correct data", secretName) | ||
| return nil | ||
| }) | ||
|
|
||
| verifyDeploymentReady := flow.Func("verifyDeploymentReady", func(ctx context.Context) error { | ||
| err := wait.PollUntilContextTimeout(ctx, 3*time.Second, 3*time.Minute, true, | ||
| func(ctx context.Context) (bool, error) { | ||
| d := &appsv1.Deployment{} | ||
| if err := cli.Get(ctx, types.NamespacedName{ | ||
| Name: deployName, Namespace: "default", | ||
| }, d); err != nil { | ||
| return false, nil | ||
| } | ||
| t.Logf("deployment %s: replicas=%d available=%d ready=%d", | ||
| deployName, | ||
| d.Status.Replicas, | ||
| d.Status.AvailableReplicas, | ||
| d.Status.ReadyReplicas) | ||
| return d.Status.AvailableReplicas >= 1, nil | ||
| }) | ||
| require.NoError(t, err, | ||
| "timed out waiting for deployment %s to have available replicas", deployName) | ||
| t.Logf("deployment %s is available", deployName) | ||
| return nil | ||
| }) | ||
|
|
||
| verifyZeroRestarts := flow.Func("verifyZeroRestarts", func(ctx context.Context) error { | ||
| podList := &corev1.PodList{} | ||
| require.NoError(t, cli.List(ctx, podList, | ||
| client.InNamespace("default"), | ||
| client.MatchingLabels{"app": deployName})) | ||
| require.NotEmpty(t, podList.Items, | ||
| "expected at least one pod for deployment %s", deployName) | ||
|
|
||
| for _, pod := range podList.Items { | ||
| for _, cs := range pod.Status.ContainerStatuses { | ||
| assert.Equal(t, int32(0), cs.RestartCount, | ||
| "pod %s container %s should have 0 restarts", pod.Name, cs.Name) | ||
| assert.True(t, cs.Ready, | ||
| "pod %s container %s should be ready", pod.Name, cs.Name) | ||
| t.Logf("pod %s container %s: restarts=%d ready=%v", | ||
| pod.Name, cs.Name, cs.RestartCount, cs.Ready) | ||
| } | ||
| } | ||
| return nil | ||
| }) | ||
|
|
||
| verifyNoContainerErrors := flow.Func("verifyNoContainerErrors", func(ctx context.Context) error { | ||
| podList := &corev1.PodList{} | ||
| require.NoError(t, cli.List(ctx, podList, | ||
| client.InNamespace("default"), | ||
| client.MatchingLabels{"app": deployName})) | ||
| require.NotEmpty(t, podList.Items) | ||
|
|
||
| for _, pod := range podList.Items { | ||
| for _, cs := range pod.Status.ContainerStatuses { | ||
| if cs.State.Waiting != nil { | ||
| t.Errorf("pod %s container %s is waiting: %s - %s", | ||
| pod.Name, cs.Name, cs.State.Waiting.Reason, cs.State.Waiting.Message) | ||
| } | ||
| if cs.State.Terminated != nil && cs.State.Terminated.ExitCode != 0 { | ||
| t.Errorf("pod %s container %s terminated with exit code %d: %s", | ||
| pod.Name, cs.Name, cs.State.Terminated.ExitCode, cs.State.Terminated.Reason) | ||
| } | ||
| } | ||
| } | ||
| t.Log("no container errors found") | ||
| return nil | ||
| }) | ||
|
|
||
| deleteComposition := fw.DeleteStep(t, "deleteComposition", cli, comp) | ||
|
|
||
| verifyResourcesDeleted := flow.Func("verifyResourcesDeleted", func(ctx context.Context) error { | ||
| d := &appsv1.Deployment{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: deployName, Namespace: "default"}, | ||
| } | ||
| s := &corev1.Secret{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "default"}, | ||
| } | ||
| fw.WaitForResourceDeleted(t, ctx, cli, d, 90*time.Second) | ||
| fw.WaitForResourceDeleted(t, ctx, cli, s, 90*time.Second) | ||
| t.Log("managed resources (deployment + secret) deleted") | ||
| return nil | ||
| }) | ||
|
|
||
| cleanupSynthesizer := fw.CleanupStep(t, "cleanupSynthesizer", cli, synth) | ||
|
|
||
| // --- Wire the workflow DAG --- | ||
|
|
||
| w := new(flow.Workflow) | ||
| w.Add( | ||
| flow.Step(createComposition).DependsOn(createSynthesizer), | ||
| flow.Step(waitCompositionReady).DependsOn(createComposition), | ||
|
|
||
| // Parallel verification after composition is ready | ||
| flow.Step(verifySecret).DependsOn(waitCompositionReady), | ||
| flow.Step(verifyDeploymentReady).DependsOn(waitCompositionReady), | ||
|
|
||
| // Pod-level checks after deployment is verified ready | ||
| flow.Step(verifyZeroRestarts).DependsOn(verifyDeploymentReady), | ||
| flow.Step(verifyNoContainerErrors).DependsOn(verifyDeploymentReady), | ||
|
|
||
| // Cleanup after all verifications pass | ||
| flow.Step(deleteComposition).DependsOn( | ||
| verifySecret, verifyZeroRestarts, verifyNoContainerErrors), | ||
| flow.Step(verifyResourcesDeleted).DependsOn(deleteComposition), | ||
| flow.Step(cleanupSynthesizer).DependsOn(verifyResourcesDeleted), | ||
| ) | ||
|
|
||
| require.NoError(t, w.Do(ctx)) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package resource | ||
|
|
||
| // managedCreateOrder maps Kind names (group-insensitive) to reserved | ||
| // readiness groups. We intentionally key on Kind alone: any resource | ||
| // whose Kind matches one of these names is treated as infrastructure | ||
| // and reconciled first, regardless of its API group. | ||
| // | ||
| // User-supplied readiness/deletion groups must be >= -60. | ||
| // Values in [-100, -60] are reserved for Eno-managed infrastructure defaults. | ||
| // Deletion groups are the negation of the create groups. | ||
| // Order derived from Helm's InstallOrder/UninstallOrder: | ||
| // https://github.com/helm/helm/blob/main/pkg/release/v1/util/kind_sorter.go | ||
| var managedCreateOrder = map[string]int{ | ||
| "PriorityClass": -100, | ||
| "Namespace": -100, | ||
| "NetworkPolicy": -99, | ||
| "ResourceQuota": -99, | ||
| "LimitRange": -99, | ||
| "PodSecurityPolicy": -98, | ||
| "PodDisruptionBudget": -98, | ||
| "ServiceAccount": -97, | ||
| "Secret": -96, | ||
| "SecretList": -96, | ||
| "ConfigMap": -96, | ||
| "StorageClass": -95, | ||
| "PersistentVolume": -94, | ||
| "PersistentVolumeClaim": -93, | ||
| "CustomResourceDefinition": -92, | ||
|
ruinan-liu marked this conversation as resolved.
|
||
| "ClusterRole": -91, | ||
| "ClusterRoleList": -91, | ||
| "ClusterRoleBinding": -91, | ||
| "ClusterRoleBindingList": -91, | ||
| "Role": -90, | ||
| "RoleList": -90, | ||
| "RoleBinding": -90, | ||
| "RoleBindingList": -90, | ||
| "Service": -89, | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.