diff --git a/controllers/account/api/v1/debt_webhook.go b/controllers/account/api/v1/debt_webhook.go index 139209db9..07f82c704 100644 --- a/controllers/account/api/v1/debt_webhook.go +++ b/controllers/account/api/v1/debt_webhook.go @@ -193,6 +193,13 @@ func (d *DebtValidate) checkOption( fmt.Sprintf("this namespace is not user namespace %s, or have not created", ns.Name), ) } + if suspendedStatus, suspended := getSuspendedNamespaceStatus(ns); suspended { + logger.V(1). + Info("deny request for suspended namespace", "ns", ns.Name, "status", suspendedStatus) + return admission.Denied( + fmt.Sprintf("namespace %s is suspended with status %s", ns.Name, suspendedStatus), + ) + } logger.V(1).Info("check user namespace", "ns", ns.Name, "user", user) // Check cache first @@ -272,6 +279,27 @@ func (d *DebtValidate) checkOption( return admission.Allowed(fmt.Sprintf("pass user %s, namespace %s", user, ns.Name)) } +func getSuspendedNamespaceStatus(ns *corev1.Namespace) (string, bool) { + debtStatus := ns.Annotations[pkgtype.DebtNamespaceAnnoStatusKey] + switch debtStatus { + case pkgtype.SuspendDebtNamespaceAnnoStatus, + pkgtype.SuspendCompletedDebtNamespaceAnnoStatus, + pkgtype.TerminateSuspendDebtNamespaceAnnoStatus, + pkgtype.TerminateSuspendCompletedDebtNamespaceAnnoStatus, + pkgtype.FinalDeletionDebtNamespaceAnnoStatus, + pkgtype.FinalDeletionCompletedDebtNamespaceAnnoStatus: + return debtStatus, true + } + + networkStatus := ns.Annotations[pkgtype.NetworkStatusAnnoKey] + switch networkStatus { + case pkgtype.NetworkSuspend, pkgtype.NetworkSuspendCompleted: + return networkStatus, true + } + + return "", false +} + func isDefaultQuotaName(name string) bool { return strings.HasPrefix(name, "quota-") || name == debtLimit0QuotaName } diff --git a/controllers/account/api/v1/debt_webhook_test.go b/controllers/account/api/v1/debt_webhook_test.go index 4d99f72de..ef38d1eab 100644 --- a/controllers/account/api/v1/debt_webhook_test.go +++ b/controllers/account/api/v1/debt_webhook_test.go @@ -194,6 +194,37 @@ func TestHandle_DeleteQuotaNotBypassed(t *testing.T) { } } +func TestHandle_SuspendedNamespace_NonDeleteDenied(t *testing.T) { + d := newDebtWithNS(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "ns-test", + Labels: map[string]string{"user.sealos.io/owner": "user-1"}, + Annotations: map[string]string{ + pkgtype.DebtNamespaceAnnoStatusKey: pkgtype.SuspendCompletedDebtNamespaceAnnoStatus, + }, + }}) + req := makeReq(admissionv1.Update, "Pod", "", "v1", "pods") + if resp := d.Handle(context.Background(), req); resp.Allowed { + t.Fatal("non-DELETE request in a suspended namespace should be denied") + } +} + +func TestHandle_SuspendedNamespace_DeleteAllowed(t *testing.T) { + d := newDebtWithNS(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "ns-test", + Labels: map[string]string{"user.sealos.io/owner": "user-1"}, + Annotations: map[string]string{ + pkgtype.DebtNamespaceAnnoStatusKey: pkgtype.SuspendCompletedDebtNamespaceAnnoStatus, + }, + }}) + req := makeReq(admissionv1.Delete, "Pod", "", "v1", "pods") + if resp := d.Handle(context.Background(), req); !resp.Allowed { + t.Fatalf( + "DELETE request in a suspended namespace should be allowed, got: %s", + resp.Result.Message, + ) + } +} + // --- Resource-type denial tests (no client/DB needed) --- func TestHandle_NamespaceDenied(t *testing.T) { @@ -324,8 +355,157 @@ func TestCheckOption_CacheHit_SufficientBalance(t *testing.T) { } } +func TestCheckOption_SuspendedDebtStatus_Denied(t *testing.T) { + statuses := []string{ + pkgtype.SuspendDebtNamespaceAnnoStatus, + pkgtype.SuspendCompletedDebtNamespaceAnnoStatus, + pkgtype.TerminateSuspendDebtNamespaceAnnoStatus, + pkgtype.TerminateSuspendCompletedDebtNamespaceAnnoStatus, + pkgtype.FinalDeletionDebtNamespaceAnnoStatus, + pkgtype.FinalDeletionCompletedDebtNamespaceAnnoStatus, + } + + for _, status := range statuses { + t.Run(status, func(t *testing.T) { + d := newDebtWithNS(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-test", + Labels: map[string]string{"user.sealos.io/owner": "user-1"}, + Annotations: map[string]string{pkgtype.DebtNamespaceAnnoStatusKey: status}, + }, + }) + if resp := d.checkOption( + context.Background(), + logger, + d.Client, + "ns-test", + ); resp.Allowed { + t.Fatalf("suspended namespace status %q should be denied", status) + } + }) + } +} + +func TestCheckOption_SuspendedNetworkStatus_Denied(t *testing.T) { + for _, status := range []string{pkgtype.NetworkSuspend, pkgtype.NetworkSuspendCompleted} { + t.Run(status, func(t *testing.T) { + d := newDebtWithNS(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-test", + Labels: map[string]string{"user.sealos.io/owner": "user-1"}, + Annotations: map[string]string{pkgtype.NetworkStatusAnnoKey: status}, + }, + }) + if resp := d.checkOption( + context.Background(), + logger, + d.Client, + "ns-test", + ); resp.Allowed { + t.Fatalf("suspended network status %q should be denied", status) + } + }) + } +} + +func TestCheckOption_ActiveDebtStatus_AllowsWithSufficientBalance(t *testing.T) { + for _, status := range []string{ + pkgtype.NormalDebtNamespaceAnnoStatus, + pkgtype.ResumeDebtNamespaceAnnoStatus, + pkgtype.ResumeCompletedDebtNamespaceAnnoStatus, + } { + t.Run(status, func(t *testing.T) { + d := newDebtWithNS(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-test", + Labels: map[string]string{"user.sealos.io/owner": "user-1"}, + Annotations: map[string]string{pkgtype.DebtNamespaceAnnoStatusKey: status}, + }, + }) + d.TTLUserMap.Put("account:user-1", &pkgtype.UsableBalanceWithCredits{ + Balance: 100, + }) + if resp := d.checkOption( + context.Background(), + logger, + d.Client, + "ns-test", + ); !resp.Allowed { + t.Fatalf( + "active namespace status %q should be allowed, got: %s", + status, + resp.Result.Message, + ) + } + }) + } +} + // --- Helpers --- +func TestGetSuspendedNamespaceStatus(t *testing.T) { + for _, status := range []string{ + pkgtype.SuspendDebtNamespaceAnnoStatus, + pkgtype.SuspendCompletedDebtNamespaceAnnoStatus, + pkgtype.TerminateSuspendDebtNamespaceAnnoStatus, + pkgtype.TerminateSuspendCompletedDebtNamespaceAnnoStatus, + pkgtype.FinalDeletionDebtNamespaceAnnoStatus, + pkgtype.FinalDeletionCompletedDebtNamespaceAnnoStatus, + } { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{pkgtype.DebtNamespaceAnnoStatusKey: status}, + }} + if got, ok := getSuspendedNamespaceStatus(ns); !ok || got != status { + t.Errorf( + "getSuspendedNamespaceStatus(%q) = (%q, %v), want (%q, true)", + status, + got, + ok, + status, + ) + } + } + for _, status := range []string{pkgtype.NetworkSuspend, pkgtype.NetworkSuspendCompleted} { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{pkgtype.NetworkStatusAnnoKey: status}, + }} + if got, ok := getSuspendedNamespaceStatus(ns); !ok || got != status { + t.Errorf( + "getSuspendedNamespaceStatus(%q) = (%q, %v), want (%q, true)", + status, + got, + ok, + status, + ) + } + } + + activeStatuses := []struct { + key string + status string + }{ + {pkgtype.DebtNamespaceAnnoStatusKey, ""}, + {pkgtype.DebtNamespaceAnnoStatusKey, pkgtype.NormalDebtNamespaceAnnoStatus}, + {pkgtype.DebtNamespaceAnnoStatusKey, pkgtype.ResumeDebtNamespaceAnnoStatus}, + {pkgtype.DebtNamespaceAnnoStatusKey, pkgtype.ResumeCompletedDebtNamespaceAnnoStatus}, + {pkgtype.NetworkStatusAnnoKey, pkgtype.NetworkResume}, + {pkgtype.NetworkStatusAnnoKey, pkgtype.NetworkResumeCompleted}, + } + for _, test := range activeStatuses { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{test.key: test.status}, + }} + if got, ok := getSuspendedNamespaceStatus(ns); ok { + t.Errorf( + "getSuspendedNamespaceStatus(%s=%q) = (%q, true), want no suspension", + test.key, + test.status, + got, + ) + } + } +} + func TestIsDefaultQuotaName(t *testing.T) { tests := []struct { name string