mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-16 01:22:00 +00:00
9f349e86ca
* feat(eks): expose NVIDIA GPUs to pods via CDI device plugin GPU nodegroups now label workers nvidia.com/gpu.present=true and taint them nvidia.com/gpu=present:NoSchedule (via a k3s config.yaml.d drop-in, since k3s has no K3S_NODE_TAINT env), and stage a hidden nvidia-device-plugin addon that addon-sync renders into the K3s auto-deploy dir. The device plugin runs with --device-list-strategy=cdi-cri and is gated to GPU nodes by nodeSelector + toleration, advertising nvidia.com/gpu allocatable so pods land on GPU nodes and get the device injected via the CDI spec baked by mulga-cdi-generate. Relies on containerd 2.0.5 default enable_cdi=true (no config fork). * fix(vm): scale QMP greeting deadline for VFIO passthrough guests A GPU passthrough guest (-device vfio-pci + large -m) must lock and DMA-map its entire guest RAM through the IOMMU before QEMU's QMP monitor answers, which exceeds the fixed 30s greeting deadline and gets the VM SIGKILLed at launch. Parameterize the greeting timeout (NewQMPClientWithGreetingTimeout) and scale it to 180s for VMs with GPU attachments; plain VMs keep the 30s default and reconnects against an already-running QEMU keep it too. * fix(eks): register GPU workers reliably and expose GPUs to pods Three fixes let a GPU nodegroup worker boot, register, and advertise its GPU: - k3s-agent.service gains Delegate=yes (plus KillMode/TasksMax/limits and overlay/br_netfilter modprobe): k3s embeds containerd+kubelet in-process, so on cgroup-v2 the kubelet cannot create /kubepods without a delegated subtree and ContainerManager.Start fails before Node registration. - The GPU node label rides a k3s config drop-in (20-gpu.yaml node-label + node-taint) instead of K3S_NODE_LABEL, which k3s ignores; without the label the nvidia-device-plugin nodeSelector never matches. - Raise the nodegroup ready timeout to 20m for the ~7min GPU first boot. Adds the GPU e2e (VPC + NAT-GW egress harness) and a slog-restore unit test. * fix(eks): install ecr-credential-provider on the GPU node AMI The GPU worker's kubelet is started (from nodegroup userdata) with --image-credential-provider-config + --image-credential-provider-bin-dir= /usr/local/bin, but the eks-node-gpu AMI never built or installed the ecr-credential-provider binary. kubelet treats a referenced-but-missing provider binary as a fatal RegisterCredentialProviderPlugins error, so the node crash-loops and never registers (while the Alpine eks-node worker, which ships the binary, joins fine). Build + install it in the GPU manifest; the server-only binaries stay omitted since GPU nodes are agent-only. Also tee k3s-agent stderr (and its embedded kubelet klog) to the serial console: Ubuntu logs only to journald, which is off-box-invisible on a worker in an isolated customer VPC, so a kubelet that fails to register left no trace. * fix(eks): give the nvidia-device-plugin its own GPU access The CDI-mode device plugin (--device-list-strategy=cdi-cri) must run nvml.Init and generate the CDI spec in its own container, but the DS had no GPU wiring, so nvml.Init crash-looped and nvidia.com/gpu was never advertised. Run the DS under the k3s-auto-configured nvidia runtimeClass with NVIDIA_VISIBLE_DEVICES/CAPABILITIES=all, the host root mounted read-only at /driver-root so it can locate the driver libs + ld.so.cache, and /var/run/cdi mounted writable so the generated spec is visible to containerd. No enable_cdi drop-in is needed on k3s v1.32.5 / containerd 2.0.5. GPU workload pods still need only resources.limits nvidia.com/gpu. Live-validated end-to-end on the single-GPU host (RTX A1000): baked control-plane AMI, GPU nodegroup, nvidia.com/gpu allocatable, nvidia-smi pod, zero manual intervention. * test(ec2): drop obsolete restoreSlogDefault test Rebased onto dev, which dropped the restoreSlogDefault workaround in #506 (viperblock no longer mutates the global slog). The test referenced the now-deleted helper and no longer compiles. Real GPU/registration diff is independently covered (diff coverage 95.3%).
481 lines
18 KiB
Go
481 lines
18 KiB
Go
//go:build e2e
|
|
|
|
package gpu
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/eks"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/aws/aws-sdk-go/service/sts"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// eksGPUInstanceType is the single worker instance type for the GPU
|
|
// nodegroup — must be advertised with GpuInfo (see requireEKSGPUFixture).
|
|
eksGPUInstanceType = "g5.xlarge"
|
|
// eksGPUVPCCIDR / eksGPUSubnetCIDR / eksGPUPublicSubnetCIDR build a
|
|
// dedicated VPC + private worker subnet + NAT-gateway egress topology
|
|
// (mirrors eks_test.go's setupClusterFixture), on a CIDR range disjoint
|
|
// from the eks suite's 10.210.0.0/16 so the two suites never collide.
|
|
eksGPUVPCCIDR = "10.212.0.0/16"
|
|
eksGPUSubnetCIDR = "10.212.1.0/24"
|
|
eksGPUPublicSubnetCIDR = "10.212.2.0/24"
|
|
eksGPUClusterPfx = "eks-gpu-e2e"
|
|
|
|
// eksNodeAMIName / eksNodeGPUAMIName are the registered EKS node AMI
|
|
// names (see spinifex/spinifex/utils/images.go). Both must be imported —
|
|
// the base image for control-plane parity checks, the GPU variant for
|
|
// resolveWorkerAMI's GPU branch.
|
|
eksNodeAMIName = "spinifex-eks-node"
|
|
eksNodeGPUAMIName = "spinifex-eks-node-gpu"
|
|
|
|
// nvidiaDevicePluginDaemonSet is the DaemonSet name from the bundled
|
|
// addon manifest (scripts/images/eks-node/addons/nvidia-device-plugin).
|
|
nvidiaDevicePluginDaemonSet = "nvidia-device-plugin-daemonset"
|
|
|
|
// nvidiaSmiPodImage carries the nvidia-smi CLI; pulled directly (not
|
|
// through the ECR mirror path), matching the busybox pattern EBS CSI e2e
|
|
// uses for its non-ECR test images.
|
|
nvidiaSmiPodImage = "nvidia/cuda:12.4.1-base-ubuntu22.04"
|
|
)
|
|
|
|
// eksGPUFixture bundles the environment + AWS client resolved once every
|
|
// requireEKSGPUFixture guard condition passes.
|
|
type eksGPUFixture struct {
|
|
Env *harness.Env
|
|
AWS *harness.AWSClient
|
|
}
|
|
|
|
// requireEKSGPUFixture skips the calling test unless every precondition for
|
|
// exercising Epic B (EKS: expose GPU to pods) is met:
|
|
// - SPINIFEX_E2E is set
|
|
// - SPINIFEX_MODE is single (this suite only targets a single-node dev box)
|
|
// - g5.xlarge is advertised with GPU capability by DescribeInstanceTypes
|
|
// - both EKS node AMIs (spinifex-eks-node, spinifex-eks-node-gpu) are imported
|
|
func requireEKSGPUFixture(t *testing.T) *eksGPUFixture {
|
|
t.Helper()
|
|
if os.Getenv("SPINIFEX_E2E") == "" {
|
|
t.Skip("SPINIFEX_E2E unset")
|
|
}
|
|
env := harness.LoadEnv(t)
|
|
if env.Mode != harness.ModeSingle {
|
|
t.Skip("eks gpu suite requires SPINIFEX_MODE=single")
|
|
}
|
|
// Give a clean skip rather than letting NewAWSClient t.Fatal when no
|
|
// Spinifex node is running (ResolveCACert uses the same candidate paths).
|
|
if _, err := harness.ResolveCACert(env); err != nil {
|
|
t.Skip("no Spinifex node running — provision first: ansible-playbook ansible/playbooks/dev-reset.yml")
|
|
}
|
|
awsCli := harness.NewAWSClient(t, env)
|
|
|
|
if reason := discoverEKSGPUPrereqs(awsCli); reason != "" {
|
|
t.Skip(reason)
|
|
}
|
|
return &eksGPUFixture{Env: env, AWS: awsCli}
|
|
}
|
|
|
|
// discoverEKSGPUPrereqs returns a non-empty skip reason unless g5.xlarge is
|
|
// advertised with GPU capability AND both EKS node AMIs are imported.
|
|
func discoverEKSGPUPrereqs(c *harness.AWSClient) string {
|
|
typesOut, err := c.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{
|
|
InstanceTypes: aws.StringSlice([]string{eksGPUInstanceType}),
|
|
})
|
|
if err != nil {
|
|
return "DescribeInstanceTypes: " + err.Error()
|
|
}
|
|
gpuAdvertised := false
|
|
for _, it := range typesOut.InstanceTypes {
|
|
if aws.StringValue(it.InstanceType) == eksGPUInstanceType && it.GpuInfo != nil && len(it.GpuInfo.Gpus) > 0 {
|
|
gpuAdvertised = true
|
|
break
|
|
}
|
|
}
|
|
if !gpuAdvertised {
|
|
return fmt.Sprintf("instance type %s not advertised with GPU capability — node has no GPU or gpu_passthrough is disabled", eksGPUInstanceType)
|
|
}
|
|
|
|
imgsOut, err := c.EC2.DescribeImages(&ec2.DescribeImagesInput{
|
|
Filters: []*ec2.Filter{
|
|
{Name: aws.String("name"), Values: aws.StringSlice([]string{eksNodeAMIName, eksNodeGPUAMIName})},
|
|
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "DescribeImages: " + err.Error()
|
|
}
|
|
seen := make(map[string]bool, len(imgsOut.Images))
|
|
for _, img := range imgsOut.Images {
|
|
seen[aws.StringValue(img.Name)] = true
|
|
}
|
|
var missing []string
|
|
for _, name := range []string{eksNodeAMIName, eksNodeGPUAMIName} {
|
|
if !seen[name] {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Sprintf("EKS node AMI(s) not imported: %s — run: spx admin images import --name <ami>", strings.Join(missing, ", "))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// TestEKSGPUPodExposure drives Epic B end-to-end: a minimal EKS cluster plus a
|
|
// 1-node GPU nodegroup, then asserts the GPU is actually visible to a pod —
|
|
// node Ready, gpu.present label + scheduling taint, device-plugin DaemonSet
|
|
// rollout, nvidia.com/gpu allocatable, and a tolerating pod running
|
|
// nvidia-smi against the passed-through device.
|
|
func TestEKSGPUPodExposure(t *testing.T) {
|
|
fx := requireEKSGPUFixture(t)
|
|
env, c := fx.Env, fx.AWS
|
|
artifacts := harness.ArtifactDir(t, env)
|
|
|
|
harness.Phase(t, "EKS GPU — resolving caller account")
|
|
ident, err := c.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{})
|
|
require.NoError(t, err, "sts get-caller-identity")
|
|
accountID := aws.StringValue(ident.Account)
|
|
|
|
harness.Phase(t, "Creating VPC topology (%s)", eksGPUVPCCIDR)
|
|
vpcID := harness.CreateVPC(t, c, eksGPUVPCCIDR)
|
|
t.Cleanup(func() { harness.DeleteVPC(t, c, vpcID) })
|
|
subnetID := harness.CreateSubnet(t, c, vpcID, eksGPUSubnetCIDR)
|
|
t.Cleanup(func() { harness.DeleteSubnet(t, c, subnetID) })
|
|
egress := harness.CreateWorkerEgress(t, c, vpcID, subnetID, eksGPUPublicSubnetCIDR)
|
|
t.Cleanup(func() { harness.DeleteWorkerEgress(t, c, egress) })
|
|
|
|
clusterName := fmt.Sprintf("%s-%d", eksGPUClusterPfx, time.Now().Unix())
|
|
harness.Phase(t, "Creating cluster %q", clusterName)
|
|
roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s-role", accountID, clusterName)
|
|
// e2e:allow-create — the GPU cluster is the subject under test (GPU worker join + device-plugin exposure).
|
|
_, err = c.EKS.CreateCluster(&eks.CreateClusterInput{
|
|
Name: aws.String(clusterName),
|
|
RoleArn: aws.String(roleArn),
|
|
ResourcesVpcConfig: &eks.VpcConfigRequest{
|
|
SubnetIds: aws.StringSlice([]string{subnetID}),
|
|
// Public access (default). The dedicated VPC has a NAT Gateway
|
|
// (harness.CreateWorkerEgress), so the private GPU worker SNATs
|
|
// out to reach the internet-facing NLB endpoint and pull images.
|
|
EndpointPublicAccess: aws.Bool(true),
|
|
},
|
|
})
|
|
require.NoError(t, err, "create-cluster")
|
|
t.Cleanup(func() { deleteEKSGPUClusterBestEffort(t, c, clusterName) })
|
|
|
|
cl := harness.WaitForEKSClusterActive(t, c, clusterName)
|
|
require.Equal(t, eks.ClusterStatusActive, aws.StringValue(cl.Status))
|
|
|
|
kcPath := writeEKSGPUKubeconfig(t, artifacts, cl)
|
|
kc := harness.NewKubectl(t, kcPath, eksGPUTokenEnv(t, env))
|
|
|
|
nodeRoleName := fmt.Sprintf("%s-node", clusterName)
|
|
harness.Phase(t, "Creating node IAM role %s", nodeRoleName)
|
|
const nodeTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
|
|
_, err = c.IAM.CreateRole(&iam.CreateRoleInput{
|
|
RoleName: aws.String(nodeRoleName),
|
|
AssumeRolePolicyDocument: aws.String(nodeTrustPolicy),
|
|
Description: aws.String("E2E GPU node role"),
|
|
})
|
|
require.NoError(t, err, "create-role (node)")
|
|
t.Cleanup(func() { deleteEKSGPUNodeRoleBestEffort(t, c, nodeRoleName) })
|
|
|
|
const nodegroup = "gpu-e2e-ng"
|
|
harness.Phase(t, "Creating GPU nodegroup %s (%s)", nodegroup, eksGPUInstanceType)
|
|
// e2e:allow-create — the GPU nodegroup is the subject under test (GPU worker join + device-plugin exposure).
|
|
_, err = c.EKS.CreateNodegroup(&eks.CreateNodegroupInput{
|
|
ClusterName: aws.String(clusterName),
|
|
NodegroupName: aws.String(nodegroup),
|
|
Subnets: aws.StringSlice([]string{subnetID}),
|
|
NodeRole: aws.String(fmt.Sprintf("arn:aws:iam::%s:role/%s", accountID, nodeRoleName)),
|
|
InstanceTypes: aws.StringSlice([]string{eksGPUInstanceType}),
|
|
ScalingConfig: &eks.NodegroupScalingConfig{
|
|
MinSize: aws.Int64(1),
|
|
MaxSize: aws.Int64(1),
|
|
DesiredSize: aws.Int64(1),
|
|
},
|
|
})
|
|
require.NoError(t, err, "create-nodegroup")
|
|
t.Cleanup(func() { deleteEKSGPUNodegroupBestEffort(t, c, clusterName, nodegroup) })
|
|
|
|
harness.Phase(t, "Waiting for nodegroup ACTIVE")
|
|
harness.EventuallyErr(t, func() error {
|
|
out, derr := c.EKS.DescribeNodegroup(&eks.DescribeNodegroupInput{
|
|
ClusterName: aws.String(clusterName),
|
|
NodegroupName: aws.String(nodegroup),
|
|
})
|
|
if derr != nil {
|
|
return fmt.Errorf("describe-nodegroup: %w", derr)
|
|
}
|
|
if s := aws.StringValue(out.Nodegroup.Status); s != eks.NodegroupStatusActive {
|
|
return fmt.Errorf("nodegroup status %q, want ACTIVE", s)
|
|
}
|
|
return nil
|
|
}, 10*time.Minute, 10*time.Second)
|
|
|
|
// (a) the GPU worker node becomes Ready.
|
|
var nodeName string
|
|
harness.Phase(t, "Waiting for GPU worker node Ready")
|
|
harness.EventuallyErr(t, func() error {
|
|
out, kerr := kc.Run(30*time.Second, "get", "nodes",
|
|
"-l", "eks.amazonaws.com/nodegroup="+nodegroup,
|
|
"-o", `jsonpath={range .items[*]}{.metadata.name}{"="}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}`)
|
|
if kerr != nil {
|
|
return fmt.Errorf("kubectl get nodes: %v\n%s", kerr, out)
|
|
}
|
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
|
name, ready, ok := strings.Cut(line, "=")
|
|
if ok && ready == "True" {
|
|
nodeName = name
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("no Ready GPU node yet:\n%s", out)
|
|
}, 8*time.Minute, 5*time.Second)
|
|
require.NotEmpty(t, nodeName, "GPU worker node name resolved")
|
|
harness.Detail(t, "node", nodeName)
|
|
|
|
// (b) node carries the GPU label and scheduling taint.
|
|
labelOut, err := kc.Run(30*time.Second, "get", "node", nodeName, "-o",
|
|
`jsonpath={.metadata.labels['nvidia\.com/gpu\.present']}`)
|
|
require.NoErrorf(t, err, "get node labels:\n%s", labelOut)
|
|
assert.Equal(t, "true", strings.TrimSpace(labelOut), "node must carry nvidia.com/gpu.present=true")
|
|
|
|
taintsOut, err := kc.Run(30*time.Second, "get", "node", nodeName, "-o",
|
|
`jsonpath={range .spec.taints[*]}{.key}={.value}:{.effect}{"\n"}{end}`)
|
|
require.NoErrorf(t, err, "get node taints:\n%s", taintsOut)
|
|
assert.Contains(t, taintsOut, "nvidia.com/gpu=present:NoSchedule", "node must carry the GPU scheduling taint")
|
|
|
|
// (c) nvidia-device-plugin-daemonset reports desired=1/ready=1.
|
|
harness.Phase(t, "Waiting for %s rollout", nvidiaDevicePluginDaemonSet)
|
|
harness.EventuallyErr(t, func() error {
|
|
out, kerr := kc.Run(30*time.Second, "-n", "kube-system", "get", "daemonset", nvidiaDevicePluginDaemonSet,
|
|
"-o", `jsonpath={.status.desiredNumberScheduled}={.status.numberReady}`)
|
|
if kerr != nil {
|
|
return fmt.Errorf("get daemonset: %v\n%s", kerr, out)
|
|
}
|
|
if strings.TrimSpace(out) != "1=1" {
|
|
return fmt.Errorf("daemonset desired=ready %q, want 1=1", strings.TrimSpace(out))
|
|
}
|
|
return nil
|
|
}, 5*time.Minute, 5*time.Second)
|
|
|
|
// (d) node.status.allocatable["nvidia.com/gpu"] == "1".
|
|
allocOut, err := kc.Run(30*time.Second, "get", "node", nodeName, "-o",
|
|
`jsonpath={.status.allocatable['nvidia\.com/gpu']}`)
|
|
require.NoErrorf(t, err, "get node allocatable:\n%s", allocOut)
|
|
assert.Equal(t, "1", strings.TrimSpace(allocOut), "node must expose exactly 1 allocatable nvidia.com/gpu")
|
|
|
|
// (e) a pod tolerating the taint and requesting nvidia.com/gpu runs
|
|
// nvidia-smi and observes the passed-through device.
|
|
const podName = "nvidia-smi-e2e"
|
|
podPath := filepath.Join(artifacts, "nvidia-smi-pod.yaml")
|
|
require.NoError(t, os.WriteFile(podPath, []byte(nvidiaSmiPodManifest(podName)), 0o600))
|
|
t.Cleanup(func() {
|
|
_, _ = kc.Run(60*time.Second, "delete", "-f", podPath, "--ignore-not-found", "--wait=false")
|
|
})
|
|
|
|
harness.Phase(t, "Applying nvidia-smi pod")
|
|
out, err := kc.Run(60*time.Second, "apply", "-f", podPath)
|
|
require.NoErrorf(t, err, "apply nvidia-smi pod:\n%s", out)
|
|
|
|
harness.EventuallyErr(t, func() error {
|
|
phase, kerr := kc.Run(30*time.Second, "get", "pod", podName, "-o", `jsonpath={.status.phase}`)
|
|
if kerr != nil {
|
|
return fmt.Errorf("get pod phase: %v\n%s", kerr, phase)
|
|
}
|
|
switch strings.TrimSpace(phase) {
|
|
case "Succeeded":
|
|
return nil
|
|
case "Failed":
|
|
desc, _ := kc.Run(30*time.Second, "describe", "pod", podName)
|
|
return fmt.Errorf("pod %s failed:\n%s", podName, desc)
|
|
default:
|
|
return fmt.Errorf("pod %s phase=%q, want Succeeded", podName, strings.TrimSpace(phase))
|
|
}
|
|
}, 5*time.Minute, 5*time.Second)
|
|
|
|
logs, err := kc.Run(30*time.Second, "logs", podName)
|
|
require.NoErrorf(t, err, "pod logs:\n%s", logs)
|
|
assert.Contains(t, logs, "NVIDIA RTX A1000", "nvidia-smi output must report the passed-through GPU model")
|
|
}
|
|
|
|
// nvidiaSmiPodManifest renders a Never-restart pod that tolerates the GPU
|
|
// scheduling taint, requests one nvidia.com/gpu, and runs nvidia-smi once.
|
|
func nvidiaSmiPodManifest(name string) string {
|
|
return fmt.Sprintf(`apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: %s
|
|
namespace: default
|
|
spec:
|
|
restartPolicy: Never
|
|
tolerations:
|
|
- key: nvidia.com/gpu
|
|
operator: Equal
|
|
value: present
|
|
effect: NoSchedule
|
|
containers:
|
|
- name: nvidia-smi
|
|
image: %s
|
|
command: ["nvidia-smi"]
|
|
resources:
|
|
limits:
|
|
nvidia.com/gpu: "1"
|
|
`, name, nvidiaSmiPodImage)
|
|
}
|
|
|
|
// writeEKSGPUKubeconfig builds a kubeconfig from DescribeCluster output,
|
|
// mirroring eks_test.go's writeKubeconfig (unexported there, so reimplemented
|
|
// locally for this package).
|
|
func writeEKSGPUKubeconfig(t *testing.T, artifacts string, cl *eks.Cluster) string {
|
|
t.Helper()
|
|
name := aws.StringValue(cl.Name)
|
|
kc := fmt.Sprintf(`apiVersion: v1
|
|
kind: Config
|
|
clusters:
|
|
- name: %[1]s
|
|
cluster:
|
|
server: %[2]s
|
|
certificate-authority-data: %[3]s
|
|
contexts:
|
|
- name: %[1]s
|
|
context:
|
|
cluster: %[1]s
|
|
user: %[1]s
|
|
current-context: %[1]s
|
|
users:
|
|
- name: %[1]s
|
|
user:
|
|
exec:
|
|
apiVersion: client.authentication.k8s.io/v1beta1
|
|
command: aws
|
|
args:
|
|
- eks
|
|
- get-token
|
|
- --cluster-name
|
|
- %[1]s
|
|
`, name, aws.StringValue(cl.Endpoint), aws.StringValue(cl.CertificateAuthority.Data))
|
|
|
|
path := filepath.Join(artifacts, "kubeconfig-"+name+".yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte(kc), 0o600), "write kubeconfig")
|
|
t.Logf("kubeconfig: %s", path)
|
|
return path
|
|
}
|
|
|
|
// eksGPUTokenEnv builds the environment kubectl's `aws eks get-token` exec
|
|
// block needs, mirroring eks_test.go's getTokenEnv.
|
|
func eksGPUTokenEnv(t *testing.T, env *harness.Env) []string {
|
|
t.Helper()
|
|
e := os.Environ()
|
|
if os.Getenv("AWS_PROFILE") == "" {
|
|
e = append(e, "AWS_PROFILE="+eksGPUEnvOr("SPINIFEX_AWS_PROFILE", "spinifex"))
|
|
}
|
|
if os.Getenv("AWS_REGION") == "" {
|
|
e = append(e, "AWS_REGION="+eksGPUEnvOr("SPINIFEX_AWS_REGION", "ap-southeast-2"))
|
|
}
|
|
if os.Getenv("AWS_CA_BUNDLE") == "" && os.Getenv("SPINIFEX_AWS_INSECURE") != "1" {
|
|
if ca, err := harness.ResolveCACert(env); err == nil {
|
|
e = append(e, "AWS_CA_BUNDLE="+ca)
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
func eksGPUEnvOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// deleteEKSGPUNodegroupBestEffort deletes the nodegroup and waits for it to
|
|
// clear, logging (not failing) on error so cluster teardown still proceeds.
|
|
func deleteEKSGPUNodegroupBestEffort(t *testing.T, c *harness.AWSClient, cluster, nodegroup string) {
|
|
t.Helper()
|
|
if _, err := c.EKS.DeleteNodegroup(&eks.DeleteNodegroupInput{
|
|
ClusterName: aws.String(cluster),
|
|
NodegroupName: aws.String(nodegroup),
|
|
}); err != nil {
|
|
if !isEKSResourceNotFound(err) {
|
|
t.Logf("cleanup delete-nodegroup %s/%s: %v", cluster, nodegroup, err)
|
|
}
|
|
return
|
|
}
|
|
harness.EventuallyErr(t, func() error {
|
|
_, err := c.EKS.DescribeNodegroup(&eks.DescribeNodegroupInput{
|
|
ClusterName: aws.String(cluster),
|
|
NodegroupName: aws.String(nodegroup),
|
|
})
|
|
if err == nil {
|
|
return fmt.Errorf("nodegroup %s/%s still exists", cluster, nodegroup)
|
|
}
|
|
if isEKSResourceNotFound(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("describe-nodegroup %s/%s: %w", cluster, nodegroup, err)
|
|
}, 5*time.Minute, 5*time.Second)
|
|
}
|
|
|
|
// deleteEKSGPUNodeRoleBestEffort detaches the node role from the instance
|
|
// profile ensureNodeInstanceProfile created for it (see nodegroup.go), then
|
|
// deletes the profile and the role, logging (not failing) on error. Cleanups
|
|
// run LIFO, so this fires after the nodegroup cleanup has already terminated
|
|
// the worker that held the instance profile.
|
|
func deleteEKSGPUNodeRoleBestEffort(t *testing.T, c *harness.AWSClient, roleName string) {
|
|
t.Helper()
|
|
if _, err := c.IAM.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{
|
|
InstanceProfileName: aws.String(roleName),
|
|
RoleName: aws.String(roleName),
|
|
}); err != nil {
|
|
t.Logf("cleanup remove-role-from-instance-profile %s: %v", roleName, err)
|
|
}
|
|
if _, err := c.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{
|
|
InstanceProfileName: aws.String(roleName),
|
|
}); err != nil {
|
|
t.Logf("cleanup delete-instance-profile %s: %v", roleName, err)
|
|
}
|
|
if _, err := c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)}); err != nil {
|
|
t.Logf("cleanup delete-role %s: %v", roleName, err)
|
|
}
|
|
}
|
|
|
|
// deleteEKSGPUClusterBestEffort deletes the cluster and waits for it to be
|
|
// gone. Registered after the nodegroup cleanup (LIFO), so the worker is
|
|
// already reclaimed before the control plane tears down.
|
|
func deleteEKSGPUClusterBestEffort(t *testing.T, c *harness.AWSClient, cluster string) {
|
|
t.Helper()
|
|
if _, err := c.EKS.DeleteCluster(&eks.DeleteClusterInput{Name: aws.String(cluster)}); err != nil {
|
|
if !isEKSResourceNotFound(err) {
|
|
t.Logf("cleanup delete-cluster %s: %v", cluster, err)
|
|
}
|
|
return
|
|
}
|
|
harness.WaitForEKSClusterDeleted(t, c, cluster)
|
|
}
|
|
|
|
// isEKSResourceNotFound reports whether err is the EKS "gone" error. The SDK
|
|
// constant is "ResourceNotFoundException" but awsgw emits the doubled
|
|
// "ResourceNotFoundExceptionException" on the wire, so match by substring
|
|
// (mirrors harness.isClusterNotFound).
|
|
func isEKSResourceNotFound(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var aerr awserr.Error
|
|
if errors.As(err, &aerr) {
|
|
return strings.Contains(aerr.Code(), "ResourceNotFound")
|
|
}
|
|
return false
|
|
}
|