diff --git a/cmd/kubectl-datadog/autoscaling/cluster/install/guess/foreignkarpenter.go b/cmd/kubectl-datadog/autoscaling/cluster/install/guess/foreignkarpenter.go new file mode 100644 index 0000000000..6142cab890 --- /dev/null +++ b/cmd/kubectl-datadog/autoscaling/cluster/install/guess/foreignkarpenter.go @@ -0,0 +1,146 @@ +package guess + +import ( + "context" + "fmt" + "log" + "slices" + "strings" + + "github.com/samber/lo" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// Datadog-namespaced labels written via the Karpenter chart's additionalLabels. +// We avoid overriding standard app.kubernetes.io/* keys: the chart's +// _helpers.tpl emits them before additionalLabels, producing duplicate YAML +// keys whose deduplication at the API server is non-deterministic. +const ( + InstalledByLabel = "autoscaling.datadoghq.com/installed-by" + InstalledByValue = "kubectl-datadog" + InstallerVersionLabel = "autoscaling.datadoghq.com/installer-version" +) + +// karpenterServiceEnvName is the env var name the upstream Karpenter chart's +// controller deployment unconditionally sets (its value is the chart's +// fullname, used by the controller to locate its own Service). Distinctive +// enough that no other workload sets it, and robust to image-registry +// rewrites — Docker Hardened Images, Chainguard, ECR pull-through caches +// all swap the image but keep this env intact. +const karpenterServiceEnvName = "KARPENTER_SERVICE" + +// karpenterControllerImageRepoSuffix is the trailing two path components of +// the upstream chart's `controller.image.repository`. Used as a secondary +// signal so chart forks that drop KARPENTER_SERVICE are still caught when +// they keep the canonical `karpenter/controller` path. Match is +// component-aware (not a substring) so `team/karpenter/controllers` or +// `someone/karpenter/controller-something` do not false-positive. +const karpenterControllerImageRepoSuffix = "karpenter/controller" + +// deploymentListChunkSize bounds the size of a single List response so we +// don't pull thousands of Deployments into memory at once on dense clusters. +// Matches the chunk size used by GetNodesProperties. +const deploymentListChunkSize = 100 + +// ForeignKarpenter is the location of a Karpenter controller Deployment +// that conflicts with the install we're about to perform — either a +// third-party install or a previous kubectl-datadog install in a different +// namespace, both of which would race the new controller on the +// cluster-scoped Karpenter CRDs. +type ForeignKarpenter struct { + Namespace string + Name string +} + +// FindForeignKarpenterInstallation returns the location of a Karpenter +// controller Deployment running on the cluster that this `install` run +// would conflict with, or nil when none is found. `targetNamespace` is the +// namespace the install would deploy into; only a kubectl-datadog +// Deployment in that namespace is treated as ours and skipped — an +// existing kubectl-datadog Deployment in a *different* namespace would +// race the new one on the cluster-scoped Karpenter CRDs, so we surface +// it too. +// +// Detection scans every Deployment for a container that either sets the +// chart-emitted `KARPENTER_SERVICE` env var or runs an image whose +// repository ends with `karpenter/controller`. Looking at the running +// controller is more robust than RBAC-based detection — monitoring or +// management roles legitimately hold permissions on the `karpenter.sh` API +// group without running a controller, and a Deployment that matches either +// container signal is the only one that distinguishes "Karpenter is +// actually running" from "something has read access to its CRs". +// +// The list is paginated with an early exit on the first foreign match: +// dense clusters with thousands of Deployments do not need to be fully +// materialised in memory just to answer "is there at least one foreign +// Karpenter Deployment". +func FindForeignKarpenterInstallation(ctx context.Context, clientset kubernetes.Interface, targetNamespace string) (*ForeignKarpenter, error) { + var cont string + for { + deps, err := clientset.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{ + Limit: deploymentListChunkSize, + Continue: cont, + }) + if err != nil { + return nil, fmt.Errorf("failed to list Deployments: %w", err) + } + + for _, dep := range deps.Items { + if !hasKarpenterControllerContainer(dep.Spec.Template.Spec.Containers) { + continue + } + if dep.Namespace == targetNamespace && dep.Labels[InstalledByLabel] == InstalledByValue { + continue + } + log.Printf("Detected foreign Karpenter Deployment %s/%s", dep.Namespace, dep.Name) + return &ForeignKarpenter{Namespace: dep.Namespace, Name: dep.Name}, nil + } + + cont = deps.Continue + if cont == "" { + return nil, nil + } + } +} + +// hasKarpenterControllerContainer reports whether any container in the pod +// spec is the Karpenter controller — primary signal is the +// chart-unconditional KARPENTER_SERVICE env var; secondary is the canonical +// `karpenter/controller` image repository tail. +func hasKarpenterControllerContainer(containers []corev1.Container) bool { + return lo.ContainsBy(containers, isKarpenterControllerContainer) +} + +func isKarpenterControllerContainer(c corev1.Container) bool { + if lo.ContainsBy(c.Env, func(e corev1.EnvVar) bool { return e.Name == karpenterServiceEnvName }) { + return true + } + return imageRepoEndsWith(c.Image, karpenterControllerImageRepoSuffix) +} + +// imageRepoEndsWith reports whether `image`'s repository path (with tag and +// digest stripped) ends with the slash-separated path components in `suffix`. +// Used to avoid false positives from `team/karpenter/controllers` or +// `someone/karpenter/controller-something`. +// +// Stripping order matters because of registries with ports +// (`registry.local:5000/...`): digest comes off first (everything after `@`), +// then a tag is recognised only when the last `:` lies after the last `/` — +// otherwise the registry's port colon would be mistaken for a tag separator. +func imageRepoEndsWith(image, suffix string) bool { + if i := strings.Index(image, "@"); i >= 0 { + image = image[:i] + } + lastSlash := strings.LastIndex(image, "/") + if lastColon := strings.LastIndex(image, ":"); lastColon > lastSlash { + image = image[:lastColon] + } + suffixParts := strings.Split(suffix, "/") + imageParts := strings.Split(image, "/") + if len(imageParts) < len(suffixParts) { + return false + } + return slices.Equal(imageParts[len(imageParts)-len(suffixParts):], suffixParts) +} diff --git a/cmd/kubectl-datadog/autoscaling/cluster/install/guess/foreignkarpenter_test.go b/cmd/kubectl-datadog/autoscaling/cluster/install/guess/foreignkarpenter_test.go new file mode 100644 index 0000000000..937e490e4a --- /dev/null +++ b/cmd/kubectl-datadog/autoscaling/cluster/install/guess/foreignkarpenter_test.go @@ -0,0 +1,305 @@ +package guess + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +// karpenterControllerImage is the upstream chart's default controller image, +// constructed exactly as the `karpenter.controller.image` helper renders it. +const karpenterControllerImage = "public.ecr.aws/karpenter/controller:1.12.0" + +// TestKarpenterControllerFingerprintContract pins the two signals we match +// on. Subsequent tests build fake Deployments via these constants, so a +// typo would silently let them pass while real Karpenter installs stop +// matching — this assertion locks the contract against the chart's +// deployment.yaml. +func TestKarpenterControllerFingerprintContract(t *testing.T) { + assert.Equal(t, "KARPENTER_SERVICE", karpenterServiceEnvName) + assert.Equal(t, "karpenter/controller", karpenterControllerImageRepoSuffix) +} + +func TestImageRepoEndsWith(t *testing.T) { + for _, tc := range []struct { + image string + suffix string + expected bool + }{ + {"public.ecr.aws/karpenter/controller:1.12.0", "karpenter/controller", true}, + {"public.ecr.aws/karpenter/controller@sha256:abc", "karpenter/controller", true}, + {"public.ecr.aws/karpenter/controller:1.12.0@sha256:abc", "karpenter/controller", true}, + {"012345678901.dkr.ecr.us-west-2.amazonaws.com/karpenter/controller:1.10.0", "karpenter/controller", true}, + {"registry.local:5000/karpenter/controller:1.12.0", "karpenter/controller", true}, + {"registry.local:5000/karpenter/controller@sha256:abc", "karpenter/controller", true}, + {"registry.local:5000/karpenter/controller:1.12.0@sha256:abc", "karpenter/controller", true}, + {"karpenter/controller", "karpenter/controller", true}, + {"team/karpenter/controllers:v1", "karpenter/controller", false}, + {"team/karpenter/controller-something:v1", "karpenter/controller", false}, + {"registry.local:5000/team/karpenter/controllers:v1", "karpenter/controller", false}, + {"public.ecr.aws/karpenter/karpenter:1.0", "karpenter/controller", false}, + {"cgr.dev/chainguard/karpenter:1.0", "karpenter/controller", false}, + {"controller", "karpenter/controller", false}, + } { + t.Run(tc.image, func(t *testing.T) { + assert.Equal(t, tc.expected, imageRepoEndsWith(tc.image, tc.suffix)) + }) + } +} + +func TestFindForeignKarpenterInstallation(t *testing.T) { + const ourNamespace = "dd-karpenter" + + for _, tc := range []struct { + name string + objects []runtime.Object + targetNamespace string + expected *ForeignKarpenter + }{ + { + name: "no Deployments on the cluster", + objects: nil, + expected: nil, + }, + { + name: "no Karpenter Deployment among unrelated ones", + objects: []runtime.Object{ + deployment("kube-system", "coredns", nil, "registry.k8s.io/coredns/coredns:v1.10.1"), + deployment("default", "nginx", nil, "nginx:1.25"), + }, + expected: nil, + }, + { + name: "only kubectl-datadog Karpenter Deployment in our target namespace", + objects: []runtime.Object{ + deployment(ourNamespace, "karpenter", + map[string]string{InstalledByLabel: InstalledByValue}, + karpenterControllerImage, + ), + }, + targetNamespace: ourNamespace, + expected: nil, + }, + { + name: "kubectl-datadog Deployment in another namespace is foreign when targeting a different namespace", + // User has an existing kubectl-datadog install in dd-karpenter and + // reruns `install --karpenter-namespace=other-ns`. The controller + // in dd-karpenter would race the new one we'd deploy in other-ns + // on the cluster-scoped CRDs, so it must surface as foreign even + // though it carries our sentinel. + objects: []runtime.Object{ + deployment(ourNamespace, "karpenter", + map[string]string{InstalledByLabel: InstalledByValue}, + karpenterControllerImage, + ), + }, + targetNamespace: "other-ns", + expected: &ForeignKarpenter{Namespace: ourNamespace, Name: "karpenter"}, + }, + { + name: "empty targetNamespace surfaces every Karpenter controller, even sentinel-labeled", + // Defensive: an empty targetNamespace means no namespace + // qualifies as ours, so any matching controller surfaces as + // foreign. The CLI never sends an empty value (the flag has a + // default), but the detector should fail closed. + objects: []runtime.Object{ + deployment(ourNamespace, "karpenter", + map[string]string{InstalledByLabel: InstalledByValue}, + karpenterControllerImage, + ), + }, + targetNamespace: "", + expected: &ForeignKarpenter{Namespace: ourNamespace, Name: "karpenter"}, + }, + { + name: "foreign Karpenter Deployment without our sentinel", + objects: []runtime.Object{ + deployment("karpenter", "karpenter", nil, karpenterControllerImage), + }, + expected: &ForeignKarpenter{Namespace: "karpenter", Name: "karpenter"}, + }, + { + name: "foreign Karpenter installed via a private mirror still matches", + // Users behind a private registry rewrite the image but keep the + // `karpenter/controller` path so the marker still hits. + objects: []runtime.Object{ + deployment("kube-system", "their-karpenter", nil, + "012345678901.dkr.ecr.us-west-2.amazonaws.com/karpenter/controller:1.10.0", + ), + }, + expected: &ForeignKarpenter{Namespace: "kube-system", Name: "their-karpenter"}, + }, + { + name: "foreign Karpenter installed with custom nameOverride", + // nameOverride only renames the Deployment object itself; the + // controller still pulls public.ecr.aws/karpenter/controller. + objects: []runtime.Object{ + deployment("autoscaling", "my-karpenter", nil, karpenterControllerImage), + }, + expected: &ForeignKarpenter{Namespace: "autoscaling", Name: "my-karpenter"}, + }, + { + name: "mix of ours-in-target-namespace and foreign returns the foreign one", + objects: []runtime.Object{ + deployment(ourNamespace, "karpenter", + map[string]string{InstalledByLabel: InstalledByValue}, + karpenterControllerImage, + ), + deployment("karpenter", "karpenter", nil, karpenterControllerImage), + }, + targetNamespace: ourNamespace, + expected: &ForeignKarpenter{Namespace: "karpenter", Name: "karpenter"}, + }, + { + name: "Datadog Cluster Agent Deployment with karpenter.sh RBAC is not the controller", + // The DD chart's cluster-agent grants karpenter.sh permissions to + // inspect Karpenter resources, but its Deployment runs the + // cluster-agent image and does not set KARPENTER_SERVICE. It + // must not trigger the guard. + objects: []runtime.Object{ + deployment("datadog-agent", "datadog-cluster-agent", nil, + "gcr.io/datadoghq/cluster-agent:7.78.1", + ), + }, + expected: nil, + }, + { + name: "hardened image without canonical path is matched via KARPENTER_SERVICE env", + // Docker Hardened Images / Chainguard ship Karpenter under + // repositories like `cgr.dev/chainguard/karpenter` whose path + // does not end in `karpenter/controller`. The chart still sets + // the KARPENTER_SERVICE env on the controller container; that + // env name is the more robust signal. + objects: []runtime.Object{ + deploymentWithEnv("kube-system", "their-karpenter", nil, + "cgr.dev/chainguard/karpenter:1.0", + []corev1.EnvVar{{Name: "KARPENTER_SERVICE", Value: "their-karpenter"}}, + ), + }, + expected: &ForeignKarpenter{Namespace: "kube-system", Name: "their-karpenter"}, + }, + { + name: "image with `controllers` plural does not false-positive", + // Defensive: a workload named `team/karpenter/controllers` + // (note plural) must not match the canonical-suffix check. + objects: []runtime.Object{ + deployment("team-ns", "controllers", nil, + "team/karpenter/controllers:v1", + ), + }, + expected: nil, + }, + { + name: "foreign sentinel value is treated as foreign", + objects: []runtime.Object{ + deployment("karpenter", "karpenter", + map[string]string{InstalledByLabel: "someone-else"}, + karpenterControllerImage, + ), + }, + expected: &ForeignKarpenter{Namespace: "karpenter", Name: "karpenter"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + clientset := fake.NewSimpleClientset(tc.objects...) + + result, err := FindForeignKarpenterInstallation(t.Context(), clientset, tc.targetNamespace) + + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } + + t.Run("API list error propagates", func(t *testing.T) { + clientset := fake.NewSimpleClientset() + clientset.PrependReactor("list", "deployments", func(_ k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewServiceUnavailable("test failure") + }) + + _, err := FindForeignKarpenterInstallation(t.Context(), clientset, ourNamespace) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to list Deployments") + }) + + t.Run("pagination follows Continue token across pages and short-circuits on first foreign match", func(t *testing.T) { + // Three pages: an empty page with a non-empty Continue token, a page + // with only our own Deployment, and a page where the foreign install + // lives. Page 4 must never be requested. + pages := []*appsv1.DeploymentList{ + { + ListMeta: metav1.ListMeta{Continue: "page2"}, + Items: nil, + }, + { + ListMeta: metav1.ListMeta{Continue: "page3"}, + Items: []appsv1.Deployment{ + *deployment("dd-karpenter", "karpenter", + map[string]string{InstalledByLabel: InstalledByValue}, + karpenterControllerImage, + ), + }, + }, + { + ListMeta: metav1.ListMeta{Continue: "page4"}, + Items: []appsv1.Deployment{ + *deployment("their-ns", "their-karpenter", nil, karpenterControllerImage), + }, + }, + { + Items: []appsv1.Deployment{ + *deployment("never", "fetched", nil, karpenterControllerImage), + }, + }, + } + + clientset := fake.NewSimpleClientset() + var calls []string + clientset.PrependReactor("list", "deployments", func(action k8stesting.Action) (bool, runtime.Object, error) { + opts := action.(k8stesting.ListActionImpl).GetListOptions() + calls = append(calls, opts.Continue) + assert.EqualValues(t, deploymentListChunkSize, opts.Limit, "Limit must be set so the API server can chunk") + require.Less(t, len(calls)-1, len(pages), + "reactor would over-fetch beyond the synthetic pages — early-exit broken") + return true, pages[len(calls)-1], nil + }) + + result, err := FindForeignKarpenterInstallation(t.Context(), clientset, "dd-karpenter") + + require.NoError(t, err) + assert.Equal(t, &ForeignKarpenter{Namespace: "their-ns", Name: "their-karpenter"}, result) + assert.Equal(t, []string{"", "page2", "page3"}, calls, + "each call must forward the previous page's Continue token, and page 4 must never be requested") + }) +} + +func deployment(namespace, name string, labels map[string]string, image string) *appsv1.Deployment { + return deploymentWithEnv(namespace, name, labels, image, nil) +} + +func deploymentWithEnv(namespace, name string, labels map[string]string, image string, env []corev1.EnvVar) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "controller", Image: image, Env: env}, + }, + }, + }, + }, + } +} diff --git a/cmd/kubectl-datadog/autoscaling/cluster/install/install.go b/cmd/kubectl-datadog/autoscaling/cluster/install/install.go index 60821f32c2..e65ff326ee 100644 --- a/cmd/kubectl-datadog/autoscaling/cluster/install/install.go +++ b/cmd/kubectl-datadog/autoscaling/cluster/install/install.go @@ -280,6 +280,12 @@ func (o *options) run(cmd *cobra.Command) error { return displayEKSAutoModeMessage(cmd, clusterName) } + if foreign, err := guess.FindForeignKarpenterInstallation(ctx, o.Clientset, karpenterNamespace); err != nil { + return fmt.Errorf("failed to check for an existing Karpenter installation: %w", err) + } else if foreign != nil { + return displayForeignKarpenterMessage(cmd, clusterName, foreign) + } + display.PrintBox(cmd.OutOrStdout(), "Installing Karpenter on cluster "+clusterName+".") cli, err := clients.Build(ctx, o.ConfigFlags, o.Clientset) @@ -513,9 +519,10 @@ func (o *options) installHelmChart(ctx context.Context, clusterName, karpenterNa // the controller can assume the role via sts:AssumeRoleWithWebIdentity. func karpenterHelmValues(clusterName string, mode InstallMode, irsaRoleArn string) map[string]any { values := map[string]any{ + // See guess.InstalledByLabel for why these keys are Datadog-namespaced. "additionalLabels": map[string]any{ - "app.kubernetes.io/managed-by": "kubectl-datadog", - "app.kubernetes.io/version": version.GetVersion(), + guess.InstalledByLabel: guess.InstalledByValue, + guess.InstallerVersionLabel: version.GetVersion(), }, "settings": map[string]any{ "clusterName": clusterName, @@ -631,6 +638,23 @@ func displayEKSAutoModeMessage(cmd *cobra.Command, clusterName string) error { return nil } +func displayForeignKarpenterMessage(cmd *cobra.Command, clusterName string, foreign *guess.ForeignKarpenter) error { + coloredURL := openAutoscalingSettingsURL(cmd, clusterName) + + display.PrintBox(cmd.OutOrStdout(), + "Karpenter is already installed on cluster "+clusterName+":", + "Deployment "+foreign.Namespace+"/"+foreign.Name+".", + "", + "kubectl-datadog has nothing to install.", + "", + "Navigate to the Autoscaling settings page", + "and select cluster to start generating recommendations:", + coloredURL, + ) + + return nil +} + func displaySuccessMessage(cmd *cobra.Command, clusterName string, createResources CreateKarpenterResources) error { coloredURL := openAutoscalingSettingsURL(cmd, clusterName) diff --git a/cmd/kubectl-datadog/autoscaling/cluster/install/install_test.go b/cmd/kubectl-datadog/autoscaling/cluster/install/install_test.go index cd81a557a4..8847dfc78e 100644 --- a/cmd/kubectl-datadog/autoscaling/cluster/install/install_test.go +++ b/cmd/kubectl-datadog/autoscaling/cluster/install/install_test.go @@ -1,9 +1,14 @@ package install import ( + "bytes" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/install/guess" ) func TestInstallMode_String(t *testing.T) { @@ -238,6 +243,63 @@ func TestInferenceMethod_Type(t *testing.T) { assert.Equal(t, "InferenceMethod", method.Type()) } +func TestKarpenterHelmValues(t *testing.T) { + t.Run("existing-nodes mode carries Datadog ownership labels and no IRSA annotation", func(t *testing.T) { + values := karpenterHelmValues("my-cluster", InstallModeExistingNodes, "") + + labels, ok := values["additionalLabels"].(map[string]any) + require.True(t, ok, "additionalLabels must be a map") + assert.Equal(t, guess.InstalledByValue, labels[guess.InstalledByLabel], + "installed-by sentinel must match what FindForeignKarpenterInstallation looks for") + assert.Contains(t, labels, guess.InstallerVersionLabel) + + settings, ok := values["settings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "my-cluster", settings["clusterName"]) + assert.Equal(t, "my-cluster", settings["interruptionQueue"]) + + assert.NotContains(t, values, "serviceAccount", + "existing-nodes mode must not annotate the ServiceAccount with an IRSA role") + }) + + t.Run("fargate mode annotates the ServiceAccount with the IRSA role ARN", func(t *testing.T) { + const arn = "arn:aws:iam::123456789012:role/dd-karpenter" + values := karpenterHelmValues("my-cluster", InstallModeFargate, arn) + + serviceAccount, ok := values["serviceAccount"].(map[string]any) + require.True(t, ok, "fargate mode must populate serviceAccount values") + annotations, ok := serviceAccount["annotations"].(map[string]any) + require.True(t, ok) + assert.Equal(t, arn, annotations["eks.amazonaws.com/role-arn"]) + }) +} + +func TestDisplayForeignKarpenterMessage(t *testing.T) { + // browser.OpenURL spawns xdg-open with non-*os.File writers, which makes + // `cmd.Wait` hang on the pipe-copy goroutine until xdg-open's descendants + // all close the write side. Empty PATH makes the LookPath probe fail and + // browser.OpenURL returns ErrNotFound without spawning anything. + t.Setenv("PATH", "") + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + cmd.SetErr(&bytes.Buffer{}) + + foreign := &guess.ForeignKarpenter{Namespace: "karpenter", Name: "karpenter"} + err := displayForeignKarpenterMessage(cmd, "my-cluster", foreign) + require.NoError(t, err, "foreign Karpenter is a successful no-op, not an error") + + rendered := out.String() + assert.Contains(t, rendered, "Karpenter is already installed on cluster my-cluster") + assert.Contains(t, rendered, "Deployment karpenter/karpenter.", + "the message must surface the foreign install's namespace/name so the user can locate it") + assert.Contains(t, rendered, "kubectl-datadog has nothing to install.") + assert.Contains(t, rendered, "Autoscaling settings page") + assert.Contains(t, rendered, "kube_cluster_name%3Amy-cluster", + "the linked URL must point at the cluster's autoscaling settings") +} + func TestValidate(t *testing.T) { for _, tc := range []struct { name string