github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/cli/demo/k3d.go (about) 1 package demo 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "io" 9 "strings" 10 "sync" 11 12 "github.com/distribution/reference" 13 "github.com/docker/docker/api/types/mount" 14 15 "github.com/tilt-dev/tilt/internal/docker" 16 "github.com/tilt-dev/tilt/pkg/logger" 17 ) 18 19 const defaultK3dImage = "docker.io/rancher/k3d:v4.4.7" 20 21 type cluster struct { 22 Name string `json:"name"` 23 } 24 25 type K3dClient struct { 26 cli docker.Client 27 k3dImage reference.Named 28 29 ensurePulled sync.Once 30 } 31 32 func NewK3dClient(cli docker.Client) *K3dClient { 33 ref, err := reference.ParseNamed(defaultK3dImage) 34 if err != nil { 35 panic(fmt.Errorf("invalid image ref %q: %v", defaultK3dImage, err)) 36 } 37 38 return &K3dClient{ 39 cli: cli, 40 k3dImage: ref, 41 } 42 } 43 44 func (k *K3dClient) ListClusters(ctx context.Context) ([]string, error) { 45 cmd := []string{"cluster", "list", "-ojson"} 46 var clusterListJson bytes.Buffer 47 stderr := logger.Get(ctx).Writer(logger.WarnLvl) 48 if err := k.command(ctx, cmd, &clusterListJson, stderr, true); err != nil { 49 return nil, err 50 } 51 52 var clusters []cluster 53 if err := json.Unmarshal(clusterListJson.Bytes(), &clusters); err != nil { 54 return nil, fmt.Errorf("invalid JSON output from cluster list: %v", err) 55 } 56 57 clusterNames := make([]string, len(clusters)) 58 for i := range clusters { 59 clusterNames[i] = clusters[i].Name 60 } 61 return clusterNames, nil 62 } 63 64 func (k *K3dClient) CreateCluster(ctx context.Context, clusterName string) error { 65 cmd := []string{ 66 "cluster", 67 "create", clusterName, 68 "--registry-create", 69 "--kubeconfig-switch-context", 70 "--kubeconfig-update-default", 71 "--no-hostip", 72 "--no-image-volume", 73 "--no-lb", 74 // k3d has a special label syntax which accepts a node filter so you can tag server/agent/LB differently 75 // since we're launching a cluster with no load balancer, there's only a single node named `server[0]`, 76 // but k3d will emit a confusing warning if we don't specify it explicitly, so this will be 77 // `dev.tilt.built=true@server[0]` 78 "--label", fmt.Sprintf("%s=true@%s", docker.BuiltLabel, "server[0]"), 79 } 80 stdout := logger.Get(ctx).Writer(logger.DebugLvl) 81 stderr := logger.Get(ctx).Writer(logger.WarnLvl) 82 if err := k.command(ctx, cmd, stdout, stderr, true); err != nil { 83 return err 84 } 85 return nil 86 } 87 88 func (k *K3dClient) DeleteCluster(ctx context.Context, clusterName string, wait bool) error { 89 cmd := []string{ 90 "cluster", 91 "delete", clusterName, 92 } 93 var stdout, stderr io.Writer 94 if wait { 95 log := logger.Get(ctx) 96 stdout = log.Writer(logger.DebugLvl) 97 stderr = logger.NewFuncLogger(log.SupportsColor(), log.Level(), 98 func(level logger.Level, fields logger.Fields, b []byte) error { 99 // there's no kubeconfig in the container so k3d will emit confusing warnings 100 // note: no kubeconfig cleanup is necessary since k3d's execution is isolated 101 // via docker, so is never touching the host filesystem, but it's a weird 102 // use case so k3d doesn't have a flag to disable kubeconfig cleanup on delete 103 if bytes.Contains(b, []byte("Failed to remove cluster details")) || 104 bytes.Contains(b, []byte("no such file or directory")) { 105 return nil 106 } 107 log.Write(logger.WarnLvl, b) 108 return nil 109 }).Writer(logger.WarnLvl) 110 } 111 if err := k.command(ctx, cmd, stdout, stderr, wait); err != nil { 112 return err 113 } 114 return nil 115 } 116 117 func (k *K3dClient) GenerateKubeconfig(ctx context.Context, clusterName string) ([]byte, error) { 118 var kubeconfigBuf bytes.Buffer 119 stderr := logger.Get(ctx).Writer(logger.WarnLvl) 120 err := k.command(ctx, []string{"kubeconfig", "get", clusterName}, &kubeconfigBuf, stderr, true) 121 if err != nil { 122 return nil, fmt.Errorf("failed to get kubeconfig: %v", err) 123 } 124 return kubeconfigBuf.Bytes(), nil 125 } 126 127 func (k *K3dClient) command(ctx context.Context, cmd []string, stdout io.Writer, stderr io.Writer, wait bool) error { 128 // lazily pull the image the first time a command is run to avoid network-induced latency checking for an 129 // up-to-date image on each command 130 k.ensurePulled.Do(func() { 131 ref, err := k.cli.ImagePull(ctx, k.k3dImage) 132 if err != nil { 133 logger.Get(ctx).Errorf("failed to pull %q image: %v", k.k3dImage, err) 134 } else { 135 k.k3dImage = ref 136 } 137 }) 138 139 runConfig := docker.RunConfig{ 140 Pull: false, 141 Stdout: stdout, 142 Stderr: stderr, 143 Image: k.k3dImage, 144 Cmd: cmd, 145 Mounts: []mount.Mount{ 146 { 147 Type: mount.TypeBind, 148 Source: "/var/run/docker.sock", 149 Target: "/var/run/docker.sock", 150 }, 151 }, 152 } 153 runResult, err := k.cli.Run(ctx, runConfig) 154 if err != nil { 155 return fmt.Errorf("failed to run `k3d %s`: %v", strings.Join(cmd, " "), err) 156 } 157 if wait { 158 defer func() { 159 if err := runResult.Close(); err != nil { 160 logger.Get(ctx).Debugf("Failed to clean up container %q: %v", runResult.ContainerID, err) 161 } 162 }() 163 status, err := runResult.Wait() 164 if err != nil { 165 return err 166 } 167 if status != 0 { 168 return fmt.Errorf("k3d exited with code: %d", status) 169 } 170 } 171 return nil 172 }