├── internal ├── dctr │ ├── .#run.go │ └── run.go ├── exec │ └── exec.go └── socat │ └── socat.go ├── test ├── docker-desktop │ ├── cluster.yaml │ ├── Dockerfile │ ├── simple-server.yaml │ ├── builder.yaml │ └── e2e.sh ├── k3d │ ├── registry.yaml │ ├── cluster.yaml │ ├── Dockerfile │ ├── simple-server.yaml │ ├── builder.yaml │ └── e2e.sh ├── kind │ ├── registry.yaml │ ├── cluster.yaml │ ├── Dockerfile │ ├── simple-server.yaml │ ├── builder.yaml │ └── e2e.sh ├── minikube │ ├── registry.yaml │ ├── cluster.yaml │ ├── Dockerfile │ ├── simple-server.yaml │ ├── builder.yaml │ └── e2e.sh ├── e2e.sh └── simple-server │ └── main.go ├── NOTICE ├── examples ├── docker-desktop.yaml ├── k3d.yaml ├── kind.yaml ├── k3d-registry.yaml ├── minikube.yaml ├── registry.yaml ├── k3d-config.yaml ├── minikube-k8s-14.yaml ├── kind_custom_config.yaml ├── kind_registry_auth.yaml └── kind_extra_args.yaml ├── pkg ├── api │ ├── k3dv1alpha4 │ │ ├── doc.go │ │ └── types.go │ ├── k3dv1alpha5 │ │ ├── doc.go │ │ └── types.go │ ├── doc.go │ ├── accessors.go │ └── schema.go ├── cmd │ ├── docs.go │ ├── analytics.go │ ├── root.go │ ├── create.go │ ├── create_registry_test.go │ ├── normalize.go │ ├── socat.go │ ├── create_cluster_test.go │ ├── create_registry.go │ ├── apply.go │ ├── docker_desktop.go │ ├── get_test.go │ ├── create_cluster.go │ ├── delete_test.go │ └── delete.go ├── cluster │ ├── options.go │ ├── docker_desktop_dial_windows.go │ ├── config.go │ ├── docker_desktop_dial.go │ ├── admin_minikube_test.go │ ├── admin.go │ ├── admin_helpers.go │ ├── docker.go │ ├── admin_docker_desktop.go │ ├── admin_k3d_test.go │ ├── admin_kind_test.go │ ├── admin_k3d.go │ └── machine.go ├── registry │ └── options.go ├── visitor │ ├── strings.go │ ├── decode.go │ └── visitor.go ├── encoding │ ├── encoding_test.go │ └── encoding.go └── docker │ ├── docker.go │ └── docker_test.go ├── .gitignore ├── docs ├── ctlptl_version.md ├── ctlptl_analytics_opt.md ├── ctlptl_docker-desktop_open.md ├── ctlptl_docker-desktop_quit.md ├── ctlptl_docker-desktop_settings.md ├── ctlptl_docker-desktop_reset-cluster.md ├── ctlptl_socat.md ├── ctlptl_analytics.md ├── ctlptl_socat_connect-remote-docker.md ├── ctlptl_create.md ├── ctlptl_delete.md ├── ctlptl_completion_powershell.md ├── ctlptl_completion_fish.md ├── ctlptl_docker-desktop_set.md ├── ctlptl_completion.md ├── ctlptl_docker-desktop.md ├── ctlptl.md ├── ctlptl_completion_bash.md ├── ctlptl_completion_zsh.md ├── ctlptl_apply.md ├── ctlptl_create_registry.md ├── ctlptl_get.md └── ctlptl_create_cluster.md ├── .golangci.yaml ├── hack ├── make-rules │ └── generated.sh ├── release.sh ├── boilerplate.go.txt ├── publish-ci-image.sh ├── release-update-docs.sh └── Dockerfile ├── Makefile ├── CONTRIBUTING.md ├── cmd └── ctlptl │ └── main.go ├── INSTALL.md ├── .circleci ├── Dockerfile └── config.yml ├── .goreleaser.yml ├── go.mod ├── CODE_OF_CONDUCT.md └── README.md /internal/dctr/.#run.go: -------------------------------------------------------------------------------- 1 | nick@dopey.7367:1766021304 -------------------------------------------------------------------------------- /test/docker-desktop/cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Cluster 3 | product: docker-desktop 4 | -------------------------------------------------------------------------------- /test/k3d/registry.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Registry 3 | name: ctlptl-test-registry 4 | port: 5005 5 | -------------------------------------------------------------------------------- /test/kind/registry.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Registry 3 | name: ctlptl-test-registry 4 | port: 5005 5 | -------------------------------------------------------------------------------- /test/minikube/registry.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Registry 3 | name: ctlptl-test-registry 4 | port: 5005 5 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | ctlptl 2 | Copyright 2022 Docker, Inc. 3 | 4 | This product includes software developed at Docker, Inc. (https://www.docker.com). 5 | -------------------------------------------------------------------------------- /examples/docker-desktop.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Cluster 3 | name: docker-desktop 4 | product: docker-desktop 5 | minCPUs: 4 6 | -------------------------------------------------------------------------------- /test/k3d/cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Cluster 3 | name: k3d-ctlptl-test-cluster 4 | product: k3d 5 | registry: ctlptl-test-registry 6 | -------------------------------------------------------------------------------- /examples/k3d.yaml: -------------------------------------------------------------------------------- 1 | # Creates a k3d cluster with a registry. 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Cluster 4 | product: k3d 5 | registry: ctlptl-k3d-registry 6 | -------------------------------------------------------------------------------- /examples/kind.yaml: -------------------------------------------------------------------------------- 1 | # Creates a kind cluster with a registry. 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Cluster 4 | product: kind 5 | registry: ctlptl-registry 6 | -------------------------------------------------------------------------------- /examples/k3d-registry.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Registry 3 | name: k3d-test-registry 4 | labels: 5 | "app": "k3d" 6 | "k3d.role": "registry" 7 | 8 | -------------------------------------------------------------------------------- /pkg/api/k3dv1alpha4/doc.go: -------------------------------------------------------------------------------- 1 | // Package k3dv1alpha4 implements the v1alpha4 apiVersion of k3d's config file. 2 | // 3 | // +k8s:deepcopy-gen=package 4 | package k3dv1alpha4 5 | -------------------------------------------------------------------------------- /pkg/api/k3dv1alpha5/doc.go: -------------------------------------------------------------------------------- 1 | // Package k3dv1alpha5 implements the v1alpha4 apiVersion of k3d's config file. 2 | // 3 | // +k8s:deepcopy-gen=package 4 | package k3dv1alpha5 5 | -------------------------------------------------------------------------------- /examples/minikube.yaml: -------------------------------------------------------------------------------- 1 | # Creates a minikube cluster with a registry. 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Cluster 4 | product: minikube 5 | registry: ctlptl-registry 6 | minCPUs: 3 7 | -------------------------------------------------------------------------------- /examples/registry.yaml: -------------------------------------------------------------------------------- 1 | # Creates a registry called ctlptl-registry available on 127.0.0.1:5002 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Registry 4 | port: 5002 5 | listenAddress: 127.0.0.1 6 | -------------------------------------------------------------------------------- /test/kind/cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Cluster 3 | name: kind-ctlptl-test-cluster 4 | product: kind 5 | registry: ctlptl-test-registry 6 | kubernetesVersion: v1.34.0 7 | -------------------------------------------------------------------------------- /test/minikube/cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Cluster 3 | name: minikube-ctlptl-test-cluster 4 | product: minikube 5 | registry: ctlptl-test-registry 6 | kubernetesVersion: v1.31.0 7 | -------------------------------------------------------------------------------- /examples/k3d-config.yaml: -------------------------------------------------------------------------------- 1 | # k3d with an embedded config 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Cluster 4 | name: k3d-config 5 | product: k3d 6 | k3d: 7 | v1alpha5Simple: 8 | network: custom-network 9 | -------------------------------------------------------------------------------- /examples/minikube-k8s-14.yaml: -------------------------------------------------------------------------------- 1 | # Creates a minikube cluster with a registry and Kubernetes v1.14 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Cluster 4 | product: minikube 5 | registry: ctlptl-registry 6 | kubernetesVersion: v1.14.0 7 | -------------------------------------------------------------------------------- /test/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Integration tests that create a full cluster. 3 | 4 | set -exo pipefail 5 | 6 | cd $(dirname $(dirname $(realpath $0))) 7 | make install 8 | test/k3d/e2e.sh 9 | test/kind/e2e.sh 10 | test/minikube/e2e.sh 11 | -------------------------------------------------------------------------------- /pkg/api/doc.go: -------------------------------------------------------------------------------- 1 | // Package v1alpha1 implements the v1alpha1 apiVersion of ctlptl's cluster 2 | // configuration 3 | // 4 | // Borrows the approach of clientcmd/api and KIND, maintaining an API similar to 5 | // other Kubernetes APIs without pulling in the API machinery. 6 | // 7 | // +k8s:deepcopy-gen=package 8 | package api 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # For Goland configuration 18 | .idea -------------------------------------------------------------------------------- /docs/ctlptl_version.md: -------------------------------------------------------------------------------- 1 | ## ctlptl version 2 | 3 | Current ctlptl version 4 | 5 | ``` 6 | ctlptl version [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for version 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 18 | 19 | ###### Auto generated by spf13/cobra on 18-Dec-2025 20 | -------------------------------------------------------------------------------- /test/simple-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func main() { 9 | log.Println("simple-server running on port 8080") 10 | err := http.ListenAndServe(":8080", http.HandlerFunc(handler)) 11 | if err != nil { 12 | log.Fatal(err) 13 | } 14 | } 15 | 16 | func handler(w http.ResponseWriter, r *http.Request) { 17 | _, _ = w.Write([]byte("hello world")) 18 | } 19 | -------------------------------------------------------------------------------- /examples/kind_custom_config.yaml: -------------------------------------------------------------------------------- 1 | # Creates a kind cluster with Kind's custom cluster config 2 | # https://pkg.go.dev/sigs.k8s.io/kind/pkg/apis/config/v1alpha4#Cluster 3 | # Creates a cluster with 2 nodes. 4 | apiVersion: ctlptl.dev/v1alpha1 5 | kind: Cluster 6 | product: kind 7 | registry: ctlptl-registry 8 | kindV1Alpha4Cluster: 9 | name: my-cluster 10 | nodes: 11 | - role: control-plane 12 | - role: worker 13 | 14 | -------------------------------------------------------------------------------- /docs/ctlptl_analytics_opt.md: -------------------------------------------------------------------------------- 1 | ## ctlptl analytics opt 2 | 3 | opt-in or -out to tilt-dev analytics collection/upload 4 | 5 | ``` 6 | ctlptl analytics opt [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for opt 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl analytics](ctlptl_analytics.md) - info and status about tilt-dev analytics 18 | 19 | ###### Auto generated by spf13/cobra on 18-Dec-2025 20 | -------------------------------------------------------------------------------- /docs/ctlptl_docker-desktop_open.md: -------------------------------------------------------------------------------- 1 | ## ctlptl docker-desktop open 2 | 3 | Open docker-desktop 4 | 5 | ``` 6 | ctlptl docker-desktop open [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for open 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl docker-desktop](ctlptl_docker-desktop.md) - Debugging tool for the Docker Desktop client 18 | 19 | ###### Auto generated by spf13/cobra on 18-Dec-2025 20 | -------------------------------------------------------------------------------- /docs/ctlptl_docker-desktop_quit.md: -------------------------------------------------------------------------------- 1 | ## ctlptl docker-desktop quit 2 | 3 | Shutdown docker-desktop 4 | 5 | ``` 6 | ctlptl docker-desktop quit [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for quit 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl docker-desktop](ctlptl_docker-desktop.md) - Debugging tool for the Docker Desktop client 18 | 19 | ###### Auto generated by spf13/cobra on 18-Dec-2025 20 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | settings: 4 | staticcheck: 5 | checks: 6 | - all 7 | - "-ST1005" # error strings should not be capitalized 8 | - "-QF1001" # demorgan's law 9 | - "-ST1021" # comment forms 10 | - "-ST1020" # comment forms 11 | - "-ST1000" # comment forms 12 | - "-ST1016" # silly naming rules 13 | - "-QF1007" # silly conditional rules 14 | -------------------------------------------------------------------------------- /test/k3d/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM golang:1.24-alpine 4 | RUN apk update && apk add bash git curl tar 5 | ENV CGO_ENABLED=0 6 | ENV KO_VERSION=0.14.1 7 | RUN curl -fsSL https://github.com/ko-build/ko/releases/download/v${KO_VERSION}/ko_${KO_VERSION}_Linux_$(uname -m).tar.gz \ 8 | | tar -xzv ko && \ 9 | mv ko /usr/local/bin/ko 10 | WORKDIR /go/github.com/tilt-dev/ctlptl/test/cluster-network 11 | ADD . . 12 | -------------------------------------------------------------------------------- /test/kind/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM golang:1.24-alpine 4 | RUN apk update && apk add bash git curl tar 5 | ENV CGO_ENABLED=0 6 | ENV KO_VERSION=0.14.1 7 | RUN curl -fsSL https://github.com/ko-build/ko/releases/download/v${KO_VERSION}/ko_${KO_VERSION}_Linux_$(uname -m).tar.gz \ 8 | | tar -xzv ko && \ 9 | mv ko /usr/local/bin/ko 10 | WORKDIR /go/github.com/tilt-dev/ctlptl/test/cluster-network 11 | ADD . . 12 | -------------------------------------------------------------------------------- /test/minikube/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM golang:1.24-alpine 4 | RUN apk update && apk add bash git curl tar 5 | ENV CGO_ENABLED=0 6 | ENV KO_VERSION=0.14.1 7 | RUN curl -fsSL https://github.com/ko-build/ko/releases/download/v${KO_VERSION}/ko_${KO_VERSION}_Linux_$(uname -m).tar.gz \ 8 | | tar -xzv ko && \ 9 | mv ko /usr/local/bin/ko 10 | WORKDIR /go/github.com/tilt-dev/ctlptl/test/cluster-network 11 | ADD . . 12 | -------------------------------------------------------------------------------- /examples/kind_registry_auth.yaml: -------------------------------------------------------------------------------- 1 | # Creates a kind cluster with Kind's custom cluster config 2 | # 3 | apiVersion: ctlptl.dev/v1alpha1 4 | kind: Cluster 5 | product: kind 6 | registry: ctlptl-registry 7 | registryAuths: 8 | - host: docker.io 9 | endpoint: https://registry-1.docker.io 10 | username: 11 | password: 12 | kindV1Alpha4Cluster: 13 | name: my-cluster 14 | nodes: 15 | - role: control-plane 16 | -------------------------------------------------------------------------------- /test/docker-desktop/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM golang:1.24-alpine 4 | RUN apk update && apk add bash git curl tar 5 | ENV CGO_ENABLED=0 6 | ENV KO_VERSION=0.14.1 7 | RUN curl -fsSL https://github.com/ko-build/ko/releases/download/v${KO_VERSION}/ko_${KO_VERSION}_Linux_$(uname -m).tar.gz \ 8 | | tar -xzv ko && \ 9 | mv ko /usr/local/bin/ko 10 | WORKDIR /go/github.com/tilt-dev/ctlptl/test/cluster-network 11 | ADD . . 12 | -------------------------------------------------------------------------------- /docs/ctlptl_docker-desktop_settings.md: -------------------------------------------------------------------------------- 1 | ## ctlptl docker-desktop settings 2 | 3 | Print the docker-desktop settings 4 | 5 | ``` 6 | ctlptl docker-desktop settings [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for settings 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl docker-desktop](ctlptl_docker-desktop.md) - Debugging tool for the Docker Desktop client 18 | 19 | ###### Auto generated by spf13/cobra on 18-Dec-2025 20 | -------------------------------------------------------------------------------- /hack/make-rules/generated.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -exuo pipefail 4 | 5 | REPO_ROOT=$(dirname $(dirname $(dirname "$0"))) 6 | cd "${REPO_ROOT}" 7 | 8 | GOROOT="$(go env GOROOT)" 9 | rm -f pkg/api/*.deepcopy.go 10 | rm -f pkg/api/*/*.deepcopy.go 11 | go install k8s.io/code-generator/cmd/deepcopy-gen@v0.31.2 12 | deepcopy-gen \ 13 | --go-header-file hack/boilerplate.go.txt \ 14 | ./pkg/api \ 15 | ./pkg/api/k3dv1alpha4 \ 16 | ./pkg/api/k3dv1alpha5 17 | -------------------------------------------------------------------------------- /pkg/api/accessors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | func (c *Cluster) GetObjectMeta() metav1.Object { 8 | return &metav1.ObjectMeta{ 9 | Name: c.Name, 10 | } 11 | } 12 | 13 | func (r *Registry) GetObjectMeta() metav1.Object { 14 | return &metav1.ObjectMeta{ 15 | Name: r.Name, 16 | } 17 | } 18 | 19 | var _ metav1.ObjectMetaAccessor = &Cluster{} 20 | var _ metav1.ObjectMetaAccessor = &Registry{} 21 | -------------------------------------------------------------------------------- /docs/ctlptl_docker-desktop_reset-cluster.md: -------------------------------------------------------------------------------- 1 | ## ctlptl docker-desktop reset-cluster 2 | 3 | Reset the docker-desktop Kubernetes cluster 4 | 5 | ``` 6 | ctlptl docker-desktop reset-cluster [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for reset-cluster 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl docker-desktop](ctlptl_docker-desktop.md) - Debugging tool for the Docker Desktop client 18 | 19 | ###### Auto generated by spf13/cobra on 18-Dec-2025 20 | -------------------------------------------------------------------------------- /docs/ctlptl_socat.md: -------------------------------------------------------------------------------- 1 | ## ctlptl socat 2 | 3 | Use socat to connect components. Experimental. 4 | 5 | ### Options 6 | 7 | ``` 8 | -h, --help help for socat 9 | ``` 10 | 11 | ### SEE ALSO 12 | 13 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 14 | * [ctlptl socat connect-remote-docker](ctlptl_socat_connect-remote-docker.md) - Connects a local port to a remote port on a machine running Docker 15 | 16 | ###### Auto generated by spf13/cobra on 18-Dec-2025 17 | -------------------------------------------------------------------------------- /docs/ctlptl_analytics.md: -------------------------------------------------------------------------------- 1 | ## ctlptl analytics 2 | 3 | info and status about tilt-dev analytics 4 | 5 | ``` 6 | ctlptl analytics 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for analytics 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 18 | * [ctlptl analytics opt](ctlptl_analytics_opt.md) - opt-in or -out to tilt-dev analytics collection/upload 19 | 20 | ###### Auto generated by spf13/cobra on 18-Dec-2025 21 | -------------------------------------------------------------------------------- /pkg/cmd/docs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/cobra/doc" 8 | ) 9 | 10 | func newDocsCommand(root *cobra.Command) *cobra.Command { 11 | return &cobra.Command{ 12 | Use: "docs [path]", 13 | Short: "Generate the markdown docs for ctlptl at [path]", 14 | Hidden: true, 15 | Args: cobra.ExactArgs(1), 16 | Run: func(_ *cobra.Command, args []string) { 17 | err := doc.GenMarkdownTree(root, args[0]) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /hack/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Do a complete release. Run on CI. 4 | 5 | set -ex 6 | 7 | if [[ "$GITHUB_TOKEN" == "" ]]; then 8 | echo "Missing GITHUB_TOKEN" 9 | exit 1 10 | fi 11 | 12 | if [[ "$DOCKER_TOKEN" == "" ]]; then 13 | echo "Missing DOCKER_TOKEN" 14 | exit 1 15 | fi 16 | 17 | DIR=$(dirname "$0") 18 | cd "$DIR/.." 19 | 20 | echo "$DOCKER_TOKEN" | docker login --username "$DOCKER_USERNAME" --password-stdin 21 | 22 | git fetch --tags 23 | goreleaser --clean 24 | 25 | VERSION=$(git describe --abbrev=0 --tags) 26 | 27 | ./hack/release-update-docs.sh "$VERSION" 28 | -------------------------------------------------------------------------------- /docs/ctlptl_socat_connect-remote-docker.md: -------------------------------------------------------------------------------- 1 | ## ctlptl socat connect-remote-docker 2 | 3 | Connects a local port to a remote port on a machine running Docker 4 | 5 | ``` 6 | ctlptl socat connect-remote-docker [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | ctlptl socat connect-remote-docker [port] 13 | 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | -h, --help help for connect-remote-docker 20 | ``` 21 | 22 | ### SEE ALSO 23 | 24 | * [ctlptl socat](ctlptl_socat.md) - Use socat to connect components. Experimental. 25 | 26 | ###### Auto generated by spf13/cobra on 18-Dec-2025 27 | -------------------------------------------------------------------------------- /test/k3d/simple-server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: simple-server 5 | labels: 6 | app: simple-server 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: simple-server 11 | template: 12 | metadata: 13 | labels: 14 | app: simple-server 15 | spec: 16 | containers: 17 | - name: simple-server 18 | image: HOST_FROM_CONTAINER_RUNTIME/simple-server 19 | ports: 20 | - containerPort: 8080 21 | livenessProbe: 22 | httpGet: 23 | path: / 24 | port: 8080 25 | -------------------------------------------------------------------------------- /test/kind/simple-server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: simple-server 5 | labels: 6 | app: simple-server 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: simple-server 11 | template: 12 | metadata: 13 | labels: 14 | app: simple-server 15 | spec: 16 | containers: 17 | - name: simple-server 18 | image: HOST_FROM_CONTAINER_RUNTIME/simple-server 19 | ports: 20 | - containerPort: 8080 21 | livenessProbe: 22 | httpGet: 23 | path: / 24 | port: 8080 25 | -------------------------------------------------------------------------------- /test/minikube/simple-server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: simple-server 5 | labels: 6 | app: simple-server 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: simple-server 11 | template: 12 | metadata: 13 | labels: 14 | app: simple-server 15 | spec: 16 | containers: 17 | - name: simple-server 18 | image: HOST_FROM_CONTAINER_RUNTIME/simple-server 19 | ports: 20 | - containerPort: 8080 21 | livenessProbe: 22 | httpGet: 23 | path: / 24 | port: 8080 25 | -------------------------------------------------------------------------------- /test/docker-desktop/simple-server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: simple-server 5 | labels: 6 | app: simple-server 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: simple-server 11 | template: 12 | metadata: 13 | labels: 14 | app: simple-server 15 | spec: 16 | containers: 17 | - name: simple-server 18 | image: ko.local/simple-server 19 | imagePullPolicy: Never 20 | ports: 21 | - containerPort: 8080 22 | livenessProbe: 23 | httpGet: 24 | path: / 25 | port: 8080 26 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Tilt Dev 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /pkg/cmd/analytics.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/tilt-dev/wmclient/pkg/analytics" 7 | ) 8 | 9 | var Version string 10 | 11 | func newAnalytics() (analytics.Analytics, error) { 12 | return analytics.NewRemoteAnalytics( 13 | "ctlptl", 14 | analytics.WithLogger(discardLogger{}), 15 | analytics.WithGlobalTags(globalTags())) 16 | } 17 | 18 | func globalTags() map[string]string { 19 | return map[string]string{ 20 | "version": Version, 21 | "os": runtime.GOOS, 22 | } 23 | } 24 | 25 | type discardLogger struct{} 26 | 27 | func (dl discardLogger) Printf(fmt string, v ...interface{}) {} 28 | -------------------------------------------------------------------------------- /pkg/cluster/options.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/fields" 5 | 6 | "github.com/tilt-dev/ctlptl/pkg/api" 7 | ) 8 | 9 | type ListOptions struct { 10 | FieldSelector string 11 | } 12 | 13 | type clusterFields api.Cluster 14 | 15 | func (cf *clusterFields) Has(field string) bool { 16 | return field == "name" || field == "product" 17 | } 18 | 19 | func (cf *clusterFields) Get(field string) string { 20 | if field == "name" { 21 | return (*api.Cluster)(cf).Name 22 | } 23 | if field == "product" { 24 | return (*api.Cluster)(cf).Product 25 | } 26 | return "" 27 | } 28 | 29 | var _ fields.Fields = &clusterFields{} 30 | -------------------------------------------------------------------------------- /examples/kind_extra_args.yaml: -------------------------------------------------------------------------------- 1 | # Creates a kind cluster with a registry. 2 | apiVersion: ctlptl.dev/v1alpha1 3 | kind: Cluster 4 | product: kind 5 | registry: ctlptl-registry 6 | kindExtraCreateArguments: 7 | # Example 1: Pass --wait to `kind create cluster` to wait for the control plane to be ready. 8 | - "--wait=2m" 9 | # Example 2: Pass --retain to `kind create cluster` to keep the containers around. 10 | # This is super useful for debugging cluster creation issues. 11 | - "--retain" 12 | # Example 3: Pass --verbosity=3 to `kind create cluster` to get more verbose output. 13 | - # This is also super useful for debugging cluster creation issues 14 | - "--verbosity=3" 15 | -------------------------------------------------------------------------------- /pkg/registry/options.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/apimachinery/pkg/fields" 7 | 8 | "github.com/tilt-dev/ctlptl/pkg/api" 9 | ) 10 | 11 | type ListOptions struct { 12 | FieldSelector string 13 | } 14 | 15 | type registryFields api.Registry 16 | 17 | func (cf *registryFields) Has(field string) bool { 18 | return field == "name" 19 | } 20 | 21 | func (cf *registryFields) Get(field string) string { 22 | if field == "name" { 23 | return (*api.Registry)(cf).Name 24 | } 25 | if field == "port" { 26 | return fmt.Sprintf("%d", (*api.Registry)(cf).Port) 27 | } 28 | return "" 29 | } 30 | 31 | var _ fields.Fields = ®istryFields{} 32 | -------------------------------------------------------------------------------- /hack/publish-ci-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | BUILDER=buildx-multiarch 6 | IMAGE_NAME=docker/tilt-ctlptl-ci 7 | 8 | docker buildx inspect $BUILDER || docker buildx create --name=$BUILDER --driver=docker-container --driver-opt=network=host 9 | docker buildx build --builder=$BUILDER --pull --platform=linux/amd64,linux/arm64 --push -t "$IMAGE_NAME" -f .circleci/Dockerfile . 10 | 11 | # add some bash code to pull the image and pull out the tag 12 | docker pull "$IMAGE_NAME" 13 | DIGEST="$(docker inspect --format '{{.RepoDigests}}' "$IMAGE_NAME" | tr -d '[]')" 14 | 15 | yq eval -i ".jobs.e2e-remote-docker.docker[0].image = \"$DIGEST\"" .circleci/config.yml 16 | 17 | -------------------------------------------------------------------------------- /docs/ctlptl_create.md: -------------------------------------------------------------------------------- 1 | ## ctlptl create 2 | 3 | Create a cluster or registry 4 | 5 | ``` 6 | ctlptl create [cluster|registry] [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | ctlptl create cluster docker-desktop 13 | ctlptl create cluster kind --registry=ctlptl-registry 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | -h, --help help for create 20 | ``` 21 | 22 | ### SEE ALSO 23 | 24 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 25 | * [ctlptl create cluster](ctlptl_create_cluster.md) - Create a cluster with the given local Kubernetes product 26 | * [ctlptl create registry](ctlptl_create_registry.md) - Create a registry with the given name 27 | 28 | ###### Auto generated by spf13/cobra on 18-Dec-2025 29 | -------------------------------------------------------------------------------- /pkg/visitor/strings.go: -------------------------------------------------------------------------------- 1 | package visitor 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func FromStrings(filenames []string, stdin io.Reader) ([]Interface, error) { 13 | result := []Interface{} 14 | for _, f := range filenames { 15 | 16 | switch { 17 | case f == "-": 18 | result = append(result, Stdin(stdin)) 19 | 20 | case strings.Index(f, "http://") == 0 || strings.Index(f, "https://") == 0: 21 | url, err := url.Parse(f) 22 | if err != nil { 23 | return nil, errors.Wrapf(err, "invalid URL %s", url) 24 | } 25 | result = append(result, URL(http.DefaultClient, f)) 26 | 27 | default: 28 | result = append(result, File(f)) 29 | 30 | } 31 | } 32 | return result, nil 33 | } 34 | -------------------------------------------------------------------------------- /test/k3d/builder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: ko-builder 5 | spec: 6 | template: 7 | metadata: 8 | labels: 9 | app: ko-builder 10 | spec: 11 | containers: 12 | - name: builder 13 | image: HOST_FROM_CONTAINER_RUNTIME/ko-builder 14 | command: 15 | - bash 16 | - "-c" 17 | - | 18 | set -e 19 | go mod init github.com/tilt-dev/test-ctlptl 20 | go get github.com/tilt-dev/ctlptl/test/simple-server@latest 21 | ko publish -B --insecure-registry github.com/tilt-dev/ctlptl/test/simple-server 22 | env: 23 | - name: KO_DOCKER_REPO 24 | value: HOST_FROM_CLUSTER_NETWORK 25 | restartPolicy: Never 26 | backoffLimit: 0 27 | -------------------------------------------------------------------------------- /test/kind/builder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: ko-builder 5 | spec: 6 | template: 7 | metadata: 8 | labels: 9 | app: ko-builder 10 | spec: 11 | containers: 12 | - name: builder 13 | image: HOST_FROM_CONTAINER_RUNTIME/ko-builder 14 | command: 15 | - bash 16 | - "-c" 17 | - | 18 | set -e 19 | go mod init github.com/tilt-dev/test-ctlptl 20 | go get github.com/tilt-dev/ctlptl/test/simple-server@latest 21 | ko publish -B --insecure-registry github.com/tilt-dev/ctlptl/test/simple-server 22 | env: 23 | - name: KO_DOCKER_REPO 24 | value: HOST_FROM_CLUSTER_NETWORK 25 | restartPolicy: Never 26 | backoffLimit: 0 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOPATH = $(shell go env GOPATH) 2 | 3 | .PHONY: generate test vendor publish-ci-image 4 | 5 | install: 6 | CGO_ENABLED=0 go install ./cmd/ctlptl 7 | 8 | test: 9 | go test -timeout 30s -v ./... 10 | 11 | generated: 12 | hack/make-rules/generated.sh 13 | 14 | fmt: 15 | goimports -w -l -local github.com/tilt-dev/ctlptl cmd/ internal/ pkg/ 16 | 17 | tidy: 18 | go mod tidy 19 | 20 | e2e: 21 | test/e2e.sh 22 | 23 | .PHONY: golangci-lint 24 | golangci-lint: $(GOLANGCILINT) 25 | $(GOPATH)/bin/golangci-lint run --verbose --timeout=120s 26 | 27 | $(GOLANGCILINT): 28 | (cd /; GO111MODULE=on GOPROXY="direct" GOSUMDB=off go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6) 29 | 30 | BUILDER=buildx-multiarch 31 | 32 | publish-ci-image: 33 | ./hack/publish-ci-image.sh 34 | -------------------------------------------------------------------------------- /test/minikube/builder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: ko-builder 5 | spec: 6 | template: 7 | metadata: 8 | labels: 9 | app: ko-builder 10 | spec: 11 | containers: 12 | - name: builder 13 | image: HOST_FROM_CONTAINER_RUNTIME/ko-builder 14 | command: 15 | - bash 16 | - "-c" 17 | - | 18 | set -e 19 | go mod init github.com/tilt-dev/test-ctlptl 20 | go get github.com/tilt-dev/ctlptl/test/simple-server@latest 21 | ko publish -B --insecure-registry github.com/tilt-dev/ctlptl/test/simple-server 22 | env: 23 | - name: KO_DOCKER_REPO 24 | value: HOST_FROM_CLUSTER_NETWORK 25 | restartPolicy: Never 26 | backoffLimit: 0 27 | -------------------------------------------------------------------------------- /pkg/visitor/decode.go: -------------------------------------------------------------------------------- 1 | package visitor 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "k8s.io/apimachinery/pkg/runtime" 6 | 7 | "github.com/tilt-dev/ctlptl/pkg/encoding" 8 | ) 9 | 10 | func DecodeAll(vs []Interface) ([]runtime.Object, error) { 11 | result := []runtime.Object{} 12 | for _, v := range vs { 13 | objs, err := Decode(v) 14 | if err != nil { 15 | return nil, err 16 | } 17 | result = append(result, objs...) 18 | } 19 | return result, nil 20 | } 21 | 22 | func Decode(v Interface) ([]runtime.Object, error) { 23 | r, err := v.Open() 24 | if err != nil { 25 | return nil, err 26 | } 27 | defer func() { 28 | _ = r.Close() 29 | }() 30 | 31 | result, err := encoding.ParseStream(r) 32 | if err != nil { 33 | return nil, errors.Wrapf(err, "visiting %s", v.Name()) 34 | } 35 | return result, nil 36 | } 37 | -------------------------------------------------------------------------------- /docs/ctlptl_delete.md: -------------------------------------------------------------------------------- 1 | ## ctlptl delete 2 | 3 | Delete a currently running cluster 4 | 5 | ``` 6 | ctlptl delete -f FILENAME [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | ctlptl delete -f cluster.yaml 13 | ctlptl delete cluster minikube 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | --cascade string If 'true', objects will be deleted recursively. For example, deleting a cluster will delete any connected registries. Defaults to 'false'. (default "false") 20 | -f, --filename strings 21 | -h, --help help for delete 22 | --ignore-not-found If the requested object does not exist the command will return exit code 0. 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 28 | 29 | ###### Auto generated by spf13/cobra on 18-Dec-2025 30 | -------------------------------------------------------------------------------- /pkg/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/tilt-dev/wmclient/pkg/analytics" 6 | ) 7 | 8 | func NewRootCommand() *cobra.Command { 9 | var rootCmd = &cobra.Command{ 10 | Use: "ctlptl [command]", 11 | Short: "Mess around with local Kubernetes clusters without consequences", 12 | Example: " ctlptl get clusters\n" + 13 | " ctlptl apply -f my-cluster.yaml", 14 | } 15 | 16 | rootCmd.AddCommand(NewCreateOptions().Command()) 17 | rootCmd.AddCommand(NewGetOptions().Command()) 18 | rootCmd.AddCommand(NewApplyOptions().Command()) 19 | rootCmd.AddCommand(NewDeleteOptions().Command()) 20 | rootCmd.AddCommand(NewDockerDesktopCommand()) 21 | rootCmd.AddCommand(newDocsCommand(rootCmd)) 22 | rootCmd.AddCommand(analytics.NewCommand()) 23 | rootCmd.AddCommand(NewSocatCommand()) 24 | 25 | return rootCmd 26 | } 27 | -------------------------------------------------------------------------------- /docs/ctlptl_completion_powershell.md: -------------------------------------------------------------------------------- 1 | ## ctlptl completion powershell 2 | 3 | Generate the autocompletion script for powershell 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for powershell. 8 | 9 | To load completions in your current shell session: 10 | 11 | ctlptl completion powershell | Out-String | Invoke-Expression 12 | 13 | To load completions for every new session, add the output of the above command 14 | to your powershell profile. 15 | 16 | 17 | ``` 18 | ctlptl completion powershell [flags] 19 | ``` 20 | 21 | ### Options 22 | 23 | ``` 24 | -h, --help help for powershell 25 | --no-descriptions disable completion descriptions 26 | ``` 27 | 28 | ### SEE ALSO 29 | 30 | * [ctlptl completion](ctlptl_completion.md) - Generate the autocompletion script for the specified shell 31 | 32 | ###### Auto generated by spf13/cobra on 18-Dec-2025 33 | -------------------------------------------------------------------------------- /docs/ctlptl_completion_fish.md: -------------------------------------------------------------------------------- 1 | ## ctlptl completion fish 2 | 3 | Generate the autocompletion script for fish 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the fish shell. 8 | 9 | To load completions in your current shell session: 10 | 11 | ctlptl completion fish | source 12 | 13 | To load completions for every new session, execute once: 14 | 15 | ctlptl completion fish > ~/.config/fish/completions/ctlptl.fish 16 | 17 | You will need to start a new shell for this setup to take effect. 18 | 19 | 20 | ``` 21 | ctlptl completion fish [flags] 22 | ``` 23 | 24 | ### Options 25 | 26 | ``` 27 | -h, --help help for fish 28 | --no-descriptions disable completion descriptions 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [ctlptl completion](ctlptl_completion.md) - Generate the autocompletion script for the specified shell 34 | 35 | ###### Auto generated by spf13/cobra on 18-Dec-2025 36 | -------------------------------------------------------------------------------- /docs/ctlptl_docker-desktop_set.md: -------------------------------------------------------------------------------- 1 | ## ctlptl docker-desktop set 2 | 3 | Set the docker-desktop settings 4 | 5 | ### Synopsis 6 | 7 | Set the docker-desktop settings 8 | 9 | The first argument is the full path to the setting. 10 | 11 | The second argument is the desired value. 12 | 13 | Most settings are scalars. vm.fileSharing is a list of paths separated by commas. 14 | 15 | ``` 16 | ctlptl docker-desktop set KEY VALUE [flags] 17 | ``` 18 | 19 | ### Examples 20 | 21 | ``` 22 | ctlptl docker-desktop set vm.resources.cpus 2 23 | ctlptl docker-desktop set kubernetes.enabled false 24 | ctlptl docker-desktop set vm.fileSharing /Users,/Volumes,/private,/tmp 25 | ``` 26 | 27 | ### Options 28 | 29 | ``` 30 | -h, --help help for set 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [ctlptl docker-desktop](ctlptl_docker-desktop.md) - Debugging tool for the Docker Desktop client 36 | 37 | ###### Auto generated by spf13/cobra on 18-Dec-2025 38 | -------------------------------------------------------------------------------- /pkg/cluster/docker_desktop_dial_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cluster 5 | 6 | import ( 7 | "net" 8 | "os" 9 | "time" 10 | 11 | "gopkg.in/natefinch/npipe.v2" 12 | ) 13 | 14 | func dockerDesktopBackendNativeSocketPaths() ([]string, error) { 15 | return []string{ 16 | `\\.\pipe\dockerBackendNativeApiServer`, 17 | `\\.\pipe\dockerWebApiServer`, 18 | }, nil 19 | } 20 | 21 | // Use npipe.Dial to create a connection. 22 | // 23 | // npipe.Dial will wait if the socket doesn't exist. Stat it first and 24 | // dial on a timeout. 25 | // 26 | // https://github.com/natefinch/npipe#func-dial 27 | func dialDockerDesktop(socketPath string) (net.Conn, error) { 28 | _, err := os.Stat(socketPath) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return npipe.DialTimeout(socketPath, 2*time.Second) 33 | } 34 | 35 | func dialDockerBackend() (net.Conn, error) { 36 | return dialDockerDesktop(`\\.\pipe\dockerBackendApiServer`) 37 | } 38 | -------------------------------------------------------------------------------- /docs/ctlptl_completion.md: -------------------------------------------------------------------------------- 1 | ## ctlptl completion 2 | 3 | Generate the autocompletion script for the specified shell 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for ctlptl for the specified shell. 8 | See each sub-command's help for details on how to use the generated script. 9 | 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for completion 15 | ``` 16 | 17 | ### SEE ALSO 18 | 19 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 20 | * [ctlptl completion bash](ctlptl_completion_bash.md) - Generate the autocompletion script for bash 21 | * [ctlptl completion fish](ctlptl_completion_fish.md) - Generate the autocompletion script for fish 22 | * [ctlptl completion powershell](ctlptl_completion_powershell.md) - Generate the autocompletion script for powershell 23 | * [ctlptl completion zsh](ctlptl_completion_zsh.md) - Generate the autocompletion script for zsh 24 | 25 | ###### Auto generated by spf13/cobra on 18-Dec-2025 26 | -------------------------------------------------------------------------------- /docs/ctlptl_docker-desktop.md: -------------------------------------------------------------------------------- 1 | ## ctlptl docker-desktop 2 | 3 | Debugging tool for the Docker Desktop client 4 | 5 | ### Examples 6 | 7 | ``` 8 | ctlptl docker-desktop settings 9 | ctlptl docker-desktop set KEY VALUE 10 | ``` 11 | 12 | ### Options 13 | 14 | ``` 15 | -h, --help help for docker-desktop 16 | ``` 17 | 18 | ### SEE ALSO 19 | 20 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 21 | * [ctlptl docker-desktop open](ctlptl_docker-desktop_open.md) - Open docker-desktop 22 | * [ctlptl docker-desktop quit](ctlptl_docker-desktop_quit.md) - Shutdown docker-desktop 23 | * [ctlptl docker-desktop reset-cluster](ctlptl_docker-desktop_reset-cluster.md) - Reset the docker-desktop Kubernetes cluster 24 | * [ctlptl docker-desktop set](ctlptl_docker-desktop_set.md) - Set the docker-desktop settings 25 | * [ctlptl docker-desktop settings](ctlptl_docker-desktop_settings.md) - Print the docker-desktop settings 26 | 27 | ###### Auto generated by spf13/cobra on 18-Dec-2025 28 | -------------------------------------------------------------------------------- /test/docker-desktop/builder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: ko-builder 5 | spec: 6 | template: 7 | metadata: 8 | labels: 9 | app: ko-builder 10 | spec: 11 | containers: 12 | - name: builder 13 | image: ko-builder 14 | imagePullPolicy: Never 15 | securityContext: 16 | privileged: true 17 | command: 18 | - bash 19 | - "-c" 20 | - | 21 | set -e 22 | go mod init github.com/tilt-dev/test-ctlptl 23 | go get github.com/tilt-dev/ctlptl/test/simple-server@latest 24 | ko publish -B --insecure-registry github.com/tilt-dev/ctlptl/test/simple-server 25 | volumeMounts: 26 | - mountPath: /var/run/docker.sock 27 | name: docker-sock 28 | readOnly: false 29 | volumes: 30 | - name: docker-sock 31 | hostPath: 32 | path: "/run/guest-services/docker.sock" 33 | type: Socket 34 | restartPolicy: Never 35 | backoffLimit: 0 36 | -------------------------------------------------------------------------------- /pkg/cmd/create.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "k8s.io/cli-runtime/pkg/genericclioptions" 8 | ) 9 | 10 | type CreateOptions struct { 11 | genericclioptions.IOStreams 12 | } 13 | 14 | func NewCreateOptions() *CreateOptions { 15 | o := &CreateOptions{ 16 | IOStreams: genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr, In: os.Stdin}, 17 | } 18 | return o 19 | } 20 | 21 | func (o *CreateOptions) Command() *cobra.Command { 22 | var cmd = &cobra.Command{ 23 | Use: "create [cluster|registry]", 24 | Short: "Create a cluster or registry", 25 | Example: " ctlptl create cluster docker-desktop\n" + 26 | " ctlptl create cluster kind --registry=ctlptl-registry", 27 | Run: o.Run, 28 | } 29 | 30 | cmd.SetOut(o.Out) 31 | cmd.SetErr(o.ErrOut) 32 | cmd.AddCommand(NewCreateClusterOptions().Command()) 33 | cmd.AddCommand(NewCreateRegistryOptions().Command()) 34 | 35 | return cmd 36 | } 37 | 38 | func (o *CreateOptions) Run(cmd *cobra.Command, args []string) { 39 | _ = cmd.Help() 40 | os.Exit(1) 41 | } 42 | -------------------------------------------------------------------------------- /test/docker-desktop/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tests creating a cluster with a registry, 3 | # building a container in that cluster, 4 | # then running that container. 5 | 6 | set -exo pipefail 7 | 8 | export DOCKER_BUILDKIT="1" 9 | 10 | cd $(dirname $(realpath $0)) 11 | CLUSTER_NAME="kind-ctlptl-test-cluster" 12 | ctlptl apply -f cluster.yaml 13 | 14 | # The ko-builder runs in an image tagged with the host as visible from the local machine. 15 | docker buildx build --load -t ko-builder . 16 | kubectl apply -f builder.yaml 17 | 18 | set +e 19 | kubectl wait --for=condition=complete job/ko-builder --timeout=180s 20 | RESULT="$?" 21 | set -e 22 | 23 | if [[ "$RESULT" != "0" ]]; then 24 | echo "ko-builder never became healthy" 25 | kubectl describe pods -l app=ko-builder 26 | kubectl logs -l app=ko-builder --all-containers 27 | exit 1 28 | fi 29 | 30 | kubectl apply -f simple-server.yaml 31 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=180s 32 | 33 | ctlptl delete -f cluster.yaml 34 | 35 | echo "docker-desktop e2e test passed!" 36 | -------------------------------------------------------------------------------- /pkg/cluster/config.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "k8s.io/cli-runtime/pkg/genericclioptions" 7 | ) 8 | 9 | type configWriter interface { 10 | SetContext(name string) error 11 | DeleteContext(name string) error 12 | SetConfig(name, value string) error 13 | } 14 | 15 | type kubeconfigWriter struct { 16 | iostreams genericclioptions.IOStreams 17 | } 18 | 19 | func (w kubeconfigWriter) SetContext(name string) error { 20 | cmd := exec.Command("kubectl", "config", "use-context", name) 21 | cmd.Stdout = w.iostreams.Out 22 | cmd.Stderr = w.iostreams.ErrOut 23 | return cmd.Run() 24 | } 25 | 26 | func (w kubeconfigWriter) DeleteContext(name string) error { 27 | cmd := exec.Command("kubectl", "config", "delete-context", name) 28 | cmd.Stdout = w.iostreams.Out 29 | cmd.Stderr = w.iostreams.ErrOut 30 | return cmd.Run() 31 | } 32 | 33 | func (w kubeconfigWriter) SetConfig(name, value string) error { 34 | cmd := exec.Command("kubectl", "config", "set", name, value) 35 | cmd.Stdout = w.iostreams.Out 36 | cmd.Stderr = w.iostreams.ErrOut 37 | return cmd.Run() 38 | } 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Hacking on ctlptl 2 | 3 | So you want to make a change to `ctlptl`! 4 | 5 | ## Contributing 6 | 7 | We welcome contributions, either as bug reports, feature requests, or pull requests. 8 | 9 | We want everyone to feel at home in this repo and its environs; please see our 10 | [**Code of Conduct**](https://docs.tilt.dev/code_of_conduct.html) for some rules 11 | that govern everyone's participation. 12 | 13 | ## Commands 14 | 15 | Most of the commands for building and testing `ctlptl` should be familiar 16 | with anyone used to developing in Golang. But we have a Makefile to wrap 17 | common commands. 18 | 19 | ### Run 20 | 21 | ``` 22 | go run ./cmd/ctlptl 23 | ``` 24 | 25 | ### Install dev version 26 | 27 | ``` 28 | make install 29 | ``` 30 | 31 | ### Unit tests 32 | 33 | ``` 34 | make test 35 | ``` 36 | 37 | ### Integration tests 38 | 39 | ``` 40 | make e2e 41 | ``` 42 | 43 | ### Release 44 | 45 | CircleCI will automatically build ctlptl releases when you push 46 | a new tag to main. 47 | 48 | ``` 49 | git pull origin main 50 | git fetch --tags 51 | git tag -a v0.x.y -m "v0.x.y" 52 | git push origin v0.x.y 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/ctlptl.md: -------------------------------------------------------------------------------- 1 | ## ctlptl 2 | 3 | Mess around with local Kubernetes clusters without consequences 4 | 5 | ### Examples 6 | 7 | ``` 8 | ctlptl get clusters 9 | ctlptl apply -f my-cluster.yaml 10 | ``` 11 | 12 | ### Options 13 | 14 | ``` 15 | -h, --help help for ctlptl 16 | ``` 17 | 18 | ### SEE ALSO 19 | 20 | * [ctlptl analytics](ctlptl_analytics.md) - info and status about tilt-dev analytics 21 | * [ctlptl apply](ctlptl_apply.md) - Apply a cluster config to the currently running clusters 22 | * [ctlptl completion](ctlptl_completion.md) - Generate the autocompletion script for the specified shell 23 | * [ctlptl create](ctlptl_create.md) - Create a cluster or registry 24 | * [ctlptl delete](ctlptl_delete.md) - Delete a currently running cluster 25 | * [ctlptl docker-desktop](ctlptl_docker-desktop.md) - Debugging tool for the Docker Desktop client 26 | * [ctlptl get](ctlptl_get.md) - Read currently running clusters and registries 27 | * [ctlptl socat](ctlptl_socat.md) - Use socat to connect components. Experimental. 28 | * [ctlptl version](ctlptl_version.md) - Current ctlptl version 29 | 30 | ###### Auto generated by spf13/cobra on 18-Dec-2025 31 | -------------------------------------------------------------------------------- /docs/ctlptl_completion_bash.md: -------------------------------------------------------------------------------- 1 | ## ctlptl completion bash 2 | 3 | Generate the autocompletion script for bash 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the bash shell. 8 | 9 | This script depends on the 'bash-completion' package. 10 | If it is not installed already, you can install it via your OS's package manager. 11 | 12 | To load completions in your current shell session: 13 | 14 | source <(ctlptl completion bash) 15 | 16 | To load completions for every new session, execute once: 17 | 18 | #### Linux: 19 | 20 | ctlptl completion bash > /etc/bash_completion.d/ctlptl 21 | 22 | #### macOS: 23 | 24 | ctlptl completion bash > $(brew --prefix)/etc/bash_completion.d/ctlptl 25 | 26 | You will need to start a new shell for this setup to take effect. 27 | 28 | 29 | ``` 30 | ctlptl completion bash 31 | ``` 32 | 33 | ### Options 34 | 35 | ``` 36 | -h, --help help for bash 37 | --no-descriptions disable completion descriptions 38 | ``` 39 | 40 | ### SEE ALSO 41 | 42 | * [ctlptl completion](ctlptl_completion.md) - Generate the autocompletion script for the specified shell 43 | 44 | ###### Auto generated by spf13/cobra on 18-Dec-2025 45 | -------------------------------------------------------------------------------- /hack/release-update-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Updates the Tilt repo with the latest version info 4 | # and regenerates the CLI docs. 5 | # 6 | # Usage: 7 | # scripts/update-tilt-repo.sh $VERSION 8 | # where VERSION is of the form 0.1.0 9 | 10 | set -euo pipefail 11 | 12 | if [[ "${GITHUB_TOKEN-}" == "" ]]; then 13 | echo "Missing GITHUB_TOKEN" 14 | exit 1 15 | fi 16 | 17 | VERSION=${1//v/} 18 | VERSION_PATTERN="^[0-9]+\\.[0-9]+\\.[0-9]+$" 19 | if ! [[ $VERSION =~ $VERSION_PATTERN ]]; then 20 | echo "Version did not match expected pattern. Actual: $VERSION" 21 | exit 1 22 | fi 23 | 24 | DIR=$(dirname "$0") 25 | cd "$DIR/.." 26 | 27 | ROOT=$(mktemp -d) 28 | git clone https://tilt-releaser:"$GITHUB_TOKEN"@github.com/tilt-dev/ctlptl "$ROOT" 29 | 30 | set -x 31 | cd "$ROOT" 32 | sed -i -E "s/CTLPTL_VERSION=\".*\"/CTLPTL_VERSION=\"$VERSION\"/" INSTALL.md 33 | sed -i -E "s/CTLPTL_VERSION = \".*\"/CTLPTL_VERSION = \"$VERSION\"/" INSTALL.md 34 | go run ./cmd/ctlptl docs ./docs 35 | git add . 36 | git config --global user.email "it@tilt.dev" 37 | git config --global user.name "Tilt Dev" 38 | git commit -a -m "Update version numbers: $VERSION" 39 | git push origin main 40 | 41 | rm -fR "$ROOT" 42 | -------------------------------------------------------------------------------- /docs/ctlptl_completion_zsh.md: -------------------------------------------------------------------------------- 1 | ## ctlptl completion zsh 2 | 3 | Generate the autocompletion script for zsh 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the zsh shell. 8 | 9 | If shell completion is not already enabled in your environment you will need 10 | to enable it. You can execute the following once: 11 | 12 | echo "autoload -U compinit; compinit" >> ~/.zshrc 13 | 14 | To load completions in your current shell session: 15 | 16 | source <(ctlptl completion zsh) 17 | 18 | To load completions for every new session, execute once: 19 | 20 | #### Linux: 21 | 22 | ctlptl completion zsh > "${fpath[1]}/_ctlptl" 23 | 24 | #### macOS: 25 | 26 | ctlptl completion zsh > $(brew --prefix)/share/zsh/site-functions/_ctlptl 27 | 28 | You will need to start a new shell for this setup to take effect. 29 | 30 | 31 | ``` 32 | ctlptl completion zsh [flags] 33 | ``` 34 | 35 | ### Options 36 | 37 | ``` 38 | -h, --help help for zsh 39 | --no-descriptions disable completion descriptions 40 | ``` 41 | 42 | ### SEE ALSO 43 | 44 | * [ctlptl completion](ctlptl_completion.md) - Generate the autocompletion script for the specified shell 45 | 46 | ###### Auto generated by spf13/cobra on 18-Dec-2025 47 | -------------------------------------------------------------------------------- /pkg/cmd/create_registry_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | apierrors "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | 13 | "github.com/tilt-dev/ctlptl/pkg/api" 14 | ) 15 | 16 | func TestCreateRegistry(t *testing.T) { 17 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 18 | o := NewCreateRegistryOptions() 19 | o.IOStreams = streams 20 | 21 | frc := &fakeRegistryController{} 22 | err := o.run(frc, "my-registry") 23 | require.NoError(t, err) 24 | assert.Equal(t, "registry.ctlptl.dev/my-registry created\n", out.String()) 25 | assert.Equal(t, "my-registry", frc.lastRegistry.Name) 26 | } 27 | 28 | type fakeRegistryController struct { 29 | lastRegistry *api.Registry 30 | } 31 | 32 | func (cd *fakeRegistryController) Apply(ctx context.Context, registry *api.Registry) (*api.Registry, error) { 33 | cd.lastRegistry = registry 34 | return registry, nil 35 | } 36 | 37 | func (cd *fakeRegistryController) Get(ctx context.Context, name string) (*api.Registry, error) { 38 | return nil, apierrors.NewNotFound(schema.GroupResource{Group: "ctlptl.dev", Resource: "registries"}, name) 39 | } 40 | -------------------------------------------------------------------------------- /docs/ctlptl_apply.md: -------------------------------------------------------------------------------- 1 | ## ctlptl apply 2 | 3 | Apply a cluster config to the currently running clusters 4 | 5 | ``` 6 | ctlptl apply -f FILENAME [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | ctlptl apply -f cluster.yaml 13 | cat cluster.yaml | ctlptl apply -f - 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | --allow-missing-template-keys If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. (default true) 20 | -f, --filename strings 21 | -h, --help help for apply 22 | -o, --output string Output format. One of: (json, yaml, name, go-template, go-template-file, template, templatefile, jsonpath, jsonpath-as-json, jsonpath-file). 23 | --show-managed-fields If true, keep the managedFields when printing objects in JSON or YAML format. 24 | --template string Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview]. 25 | ``` 26 | 27 | ### SEE ALSO 28 | 29 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 30 | 31 | ###### Auto generated by spf13/cobra on 18-Dec-2025 32 | -------------------------------------------------------------------------------- /pkg/cmd/normalize.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/tilt-dev/clusterid" 7 | "k8s.io/apimachinery/pkg/api/errors" 8 | 9 | "github.com/tilt-dev/ctlptl/pkg/api" 10 | ) 11 | 12 | type clusterGetter interface { 13 | Get(ctx context.Context, name string) (*api.Cluster, error) 14 | } 15 | 16 | // We create clusters like: 17 | // ctlptl create cluster kind 18 | // For most clusters, the name of the cluster will match the name of the product. 19 | // But for cases where they don't match, we want 20 | // `ctlptl delete cluster kind` to automatically map to `ctlptl delete cluster kind-kind` 21 | func normalizedGet(ctx context.Context, controller clusterGetter, name string) (*api.Cluster, error) { 22 | cluster, err := controller.Get(ctx, name) 23 | if err == nil { 24 | return cluster, nil 25 | } 26 | 27 | if !errors.IsNotFound(err) { 28 | return nil, err 29 | } 30 | 31 | origErr := err 32 | retryName := "" 33 | if name == string(clusterid.ProductKIND) { 34 | retryName = clusterid.ProductKIND.DefaultClusterName() 35 | } else if name == string(clusterid.ProductK3D) { 36 | retryName = clusterid.ProductK3D.DefaultClusterName() 37 | } 38 | 39 | if retryName == "" { 40 | return nil, origErr 41 | } 42 | 43 | cluster, err = controller.Get(ctx, retryName) 44 | if err == nil { 45 | return cluster, nil 46 | } 47 | return nil, origErr 48 | } 49 | -------------------------------------------------------------------------------- /cmd/ctlptl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "runtime/debug" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/pflag" 12 | "k8s.io/klog/v2" 13 | 14 | "github.com/tilt-dev/ctlptl/pkg/cmd" 15 | ) 16 | 17 | // Magic variables set by goreleaser 18 | var version string 19 | var date string 20 | 21 | func main() { 22 | cmd.Version = version 23 | 24 | command := cmd.NewRootCommand() 25 | command.AddCommand(newVersionCommand()) 26 | 27 | klog.InitFlags(nil) 28 | 29 | pflag.CommandLine.AddGoFlagSet(flag.CommandLine) 30 | pflag.VisitAll(func(f *pflag.Flag) { 31 | f.Hidden = true 32 | }) 33 | 34 | if err := command.Execute(); err != nil { 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | func newVersionCommand() *cobra.Command { 40 | return &cobra.Command{ 41 | Use: "version", 42 | Short: "Current ctlptl version", 43 | Run: func(_ *cobra.Command, args []string) { 44 | fmt.Println(versionStamp()) 45 | }, 46 | } 47 | } 48 | 49 | func versionStamp() string { 50 | timeIndex := strings.Index(date, "T") 51 | if timeIndex != -1 { 52 | date = date[0:timeIndex] 53 | } 54 | 55 | if date == "" { 56 | date = "unknown" 57 | } 58 | 59 | if version == "" { 60 | version = "0.0.0-main" 61 | if buildInfo, ok := debug.ReadBuildInfo(); ok { 62 | version = buildInfo.Main.Version 63 | } 64 | } 65 | 66 | return fmt.Sprintf("v%s, built %s", version, date) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cluster/docker_desktop_dial.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package cluster 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "path/filepath" 10 | "runtime" 11 | 12 | "github.com/mitchellh/go-homedir" 13 | ) 14 | 15 | func dockerDesktopBackendNativeSocketPaths() ([]string, error) { 16 | socketDir, err := dockerDesktopSocketDir() 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return []string{ 22 | // Newer versions of docker desktop use this socket. 23 | filepath.Join(socketDir, "backend.native.sock"), 24 | 25 | // Older versions of docker desktop use this socket. 26 | filepath.Join(socketDir, "gui-api.sock"), 27 | }, nil 28 | } 29 | 30 | func dialDockerDesktop(socketPath string) (net.Conn, error) { 31 | return net.Dial("unix", socketPath) 32 | } 33 | 34 | func dialDockerBackend() (net.Conn, error) { 35 | socketDir, err := dockerDesktopSocketDir() 36 | if err != nil { 37 | return nil, err 38 | } 39 | return dialDockerDesktop(filepath.Join(socketDir, "backend.sock")) 40 | } 41 | 42 | func dockerDesktopSocketDir() (string, error) { 43 | homedir, err := homedir.Dir() 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | switch runtime.GOOS { 49 | case "darwin": 50 | return filepath.Join(homedir, "Library/Containers/com.docker.docker/Data"), nil 51 | case "linux": 52 | return filepath.Join(homedir, ".docker/desktop"), nil 53 | } 54 | return "", fmt.Errorf("Cannot find docker desktop directory on %s", runtime.GOOS) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cluster/admin_minikube_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "k8s.io/cli-runtime/pkg/genericclioptions" 11 | 12 | "github.com/tilt-dev/ctlptl/internal/exec" 13 | "github.com/tilt-dev/ctlptl/pkg/api" 14 | ) 15 | 16 | func TestMinikubeStartFlags(t *testing.T) { 17 | f := newMinikubeFixture() 18 | ctx := context.Background() 19 | err := f.a.Create(ctx, &api.Cluster{Name: "minikube", Minikube: &api.MinikubeCluster{StartFlags: []string{"--foo"}}}, nil) 20 | require.NoError(t, err) 21 | assert.Equal(t, []string{ 22 | "minikube", "start", 23 | "--foo", 24 | "-p", "minikube", 25 | "--driver=docker", 26 | "--container-runtime=containerd", 27 | "--extra-config=kubelet.max-pods=500", 28 | }, f.runner.LastArgs) 29 | } 30 | 31 | type minikubeFixture struct { 32 | runner *exec.FakeCmdRunner 33 | a *minikubeAdmin 34 | } 35 | 36 | func newMinikubeFixture() *minikubeFixture { 37 | dockerClient := &fakeDockerClient{ncpu: 1} 38 | iostreams := genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr} 39 | runner := exec.NewFakeCmdRunner(func(argv []string) string { 40 | if argv[1] == "version" { 41 | return `{"commit":"62e108c3dfdec8029a890ad6d8ef96b6461426dc","minikubeVersion":"v1.25.2"}` 42 | } 43 | return "" 44 | }) 45 | return &minikubeFixture{ 46 | runner: runner, 47 | a: newMinikubeAdmin(iostreams, dockerClient, runner), 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cmd/socat.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/spf13/cobra" 10 | "k8s.io/cli-runtime/pkg/genericclioptions" 11 | 12 | "github.com/tilt-dev/ctlptl/internal/dctr" 13 | "github.com/tilt-dev/ctlptl/internal/socat" 14 | ) 15 | 16 | func NewSocatCommand() *cobra.Command { 17 | var cmd = &cobra.Command{ 18 | Use: "socat", 19 | Short: "Use socat to connect components. Experimental.", 20 | } 21 | 22 | cmd.AddCommand(&cobra.Command{ 23 | Use: "connect-remote-docker", 24 | Short: "Connects a local port to a remote port on a machine running Docker", 25 | Example: " ctlptl socat connect-remote-docker [port]\n", 26 | Run: connectRemoteDocker, 27 | Args: cobra.ExactArgs(1), 28 | }) 29 | 30 | return cmd 31 | } 32 | 33 | func connectRemoteDocker(cmd *cobra.Command, args []string) { 34 | port, err := strconv.Atoi(args[0]) 35 | if err != nil { 36 | _, _ = fmt.Fprintf(os.Stderr, "connect-remote-docker: %v\n", err) 37 | os.Exit(1) 38 | } 39 | 40 | ctx := context.Background() 41 | streams := genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr} 42 | dockerCLI, err := dctr.NewCLI(streams) 43 | if err != nil { 44 | _, _ = fmt.Fprintf(os.Stderr, "connect-remote-docker: %v\n", err) 45 | os.Exit(1) 46 | } 47 | 48 | c := socat.NewController(dockerCLI) 49 | err = c.ConnectRemoteDockerPort(ctx, port) 50 | if err != nil { 51 | _, _ = fmt.Fprintf(os.Stderr, "connect-remote-docker: %v\n", err) 52 | os.Exit(1) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cluster/admin.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/tilt-dev/localregistry-go" 7 | 8 | "github.com/tilt-dev/ctlptl/internal/dctr" 9 | "github.com/tilt-dev/ctlptl/pkg/api" 10 | ) 11 | 12 | // A cluster admin provides the basic start/stop functionality of a cluster, 13 | // independent of the configuration of the machine it's running on. 14 | type Admin interface { 15 | EnsureInstalled(ctx context.Context) error 16 | 17 | // Create a new cluster. 18 | // 19 | // Make a best effort attempt to delete any resources that might block creation 20 | // of the cluster. 21 | Create(ctx context.Context, desired *api.Cluster, registry *api.Registry) error 22 | 23 | // Infers the LocalRegistryHosting that this admin will try to configure. 24 | LocalRegistryHosting(ctx context.Context, desired *api.Cluster, registry *api.Registry) (*localregistry.LocalRegistryHostingV1, error) 25 | 26 | Delete(ctx context.Context, config *api.Cluster) error 27 | } 28 | 29 | // An extension of cluster admin that indicates the cluster configuration can be 30 | // modified for use from inside containers. 31 | type AdminInContainer interface { 32 | ModifyConfigInContainer(ctx context.Context, cluster *api.Cluster, containerID string, dockerClient dctr.Client, configWriter configWriter) error 33 | } 34 | 35 | // Containerd made major changes to their config format for 36 | // configuring registries. Each cluster has its own way 37 | // of detecting this. 38 | 39 | type containerdRegistryAPI int 40 | 41 | const ( 42 | containerdRegistryV1 containerdRegistryAPI = iota 43 | containerdRegistryV2 44 | containerdRegistryBroken 45 | ) 46 | -------------------------------------------------------------------------------- /pkg/cluster/admin_helpers.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | "k8s.io/cli-runtime/pkg/genericclioptions" 10 | 11 | "github.com/tilt-dev/ctlptl/internal/exec" 12 | "github.com/tilt-dev/ctlptl/pkg/api" 13 | ) 14 | 15 | func applyContainerdPatchRegistryAPIV2( 16 | ctx context.Context, runner exec.CmdRunner, iostreams genericclioptions.IOStreams, 17 | nodes []string, desired *api.Cluster, registry *api.Registry) error { 18 | for _, node := range nodes { 19 | contents := fmt.Sprintf(`[host."http://%s:%d"] 20 | `, registry.Name, registry.Status.ContainerPort) 21 | 22 | localRegistryDir := fmt.Sprintf("/etc/containerd/certs.d/localhost:%d", registry.Status.HostPort) 23 | err := runner.RunIO(ctx, 24 | genericclioptions.IOStreams{In: strings.NewReader(contents), Out: iostreams.Out, ErrOut: iostreams.ErrOut}, 25 | "docker", "exec", "-i", node, "sh", "-c", 26 | fmt.Sprintf("mkdir -p %s && cp /dev/stdin %s/hosts.toml", localRegistryDir, localRegistryDir)) 27 | if err != nil { 28 | return errors.Wrap(err, "configuring registry") 29 | } 30 | 31 | networkRegistryDir := fmt.Sprintf("/etc/containerd/certs.d/%s:%d", registry.Name, registry.Status.ContainerPort) 32 | err = runner.RunIO(ctx, 33 | genericclioptions.IOStreams{In: strings.NewReader(contents), Out: iostreams.Out, ErrOut: iostreams.ErrOut}, 34 | "docker", "exec", "-i", node, "sh", "-c", 35 | fmt.Sprintf("mkdir -p %s && cp /dev/stdin %s/hosts.toml", networkRegistryDir, networkRegistryDir)) 36 | if err != nil { 37 | return errors.Wrap(err, "configuring registry") 38 | } 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/encoding/encoding_test.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/tilt-dev/ctlptl/pkg/api" 11 | ) 12 | 13 | func TestParse(t *testing.T) { 14 | yaml := ` 15 | apiVersion: ctlptl.dev/v1alpha1 16 | kind: Cluster 17 | name: microk8s 18 | product: microk8s 19 | --- 20 | apiVersion: ctlptl.dev/v1alpha1 21 | kind: Cluster 22 | name: kind-kind 23 | product: KIND 24 | ` 25 | data, err := ParseStream(strings.NewReader(yaml)) 26 | assert.NoError(t, err) 27 | require.Equal(t, 2, len(data)) 28 | assert.Equal(t, "microk8s", data[0].(*api.Cluster).Name) 29 | assert.Equal(t, "kind-kind", data[1].(*api.Cluster).Name) 30 | } 31 | 32 | func TestParseTypo(t *testing.T) { 33 | yaml := ` 34 | apiVersion: ctlptl.dev/v1alpha1 35 | kind: Cluster 36 | nameTypo: microk8s 37 | product: microk8s 38 | ` 39 | _, err := ParseStream(strings.NewReader(yaml)) 40 | if assert.Error(t, err) { 41 | assert.Contains(t, err.Error(), "decoding {Cluster ctlptl.dev/v1alpha1}: yaml: unmarshal errors:\n line 4: field nameTypo not found in type api.Cluster") 42 | } 43 | } 44 | 45 | func TestParseTypoSecondObject(t *testing.T) { 46 | yaml := ` 47 | apiVersion: ctlptl.dev/v1alpha1 48 | kind: Cluster 49 | name: microk8s 50 | product: microk8s 51 | --- 52 | apiVersion: ctlptl.dev/v1alpha1 53 | kind: Cluster 54 | nameTypo: microk8s 55 | product: microk8s 56 | ` 57 | _, err := ParseStream(strings.NewReader(yaml)) 58 | if assert.Error(t, err) { 59 | assert.Contains(t, err.Error(), "decoding {Cluster ctlptl.dev/v1alpha1}: yaml: unmarshal errors:\n line 9: field nameTypo not found in type api.Cluster") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/ctlptl_create_registry.md: -------------------------------------------------------------------------------- 1 | ## ctlptl create registry 2 | 3 | Create a registry with the given name 4 | 5 | ``` 6 | ctlptl create registry [name] [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | ctlptl create registry ctlptl-registry 13 | ctlptl create registry ctlptl-registry --port=5000 14 | ctlptl create registry ctlptl-registry --port=5000 --listen-address 0.0.0.0 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | --allow-missing-template-keys If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. (default true) 21 | -h, --help help for registry 22 | --image string Registry image to use (default "docker.io/library/registry:2") 23 | --listen-address string The host's IP address to bind the container to. If not set defaults to 127.0.0.1 24 | -o, --output string Output format. One of: (json, yaml, name, go-template, go-template-file, template, templatefile, jsonpath, jsonpath-as-json, jsonpath-file). 25 | --port int The port to expose the registry on host. If not specified, chooses a random port 26 | --show-managed-fields If true, keep the managedFields when printing objects in JSON or YAML format. 27 | --template string Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview]. 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [ctlptl create](ctlptl_create.md) - Create a cluster or registry 33 | 34 | ###### Auto generated by spf13/cobra on 18-Dec-2025 35 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # ctlptl Installation Appendix 2 | 3 | ## Recommended 4 | 5 | ### Homebrew (Mac/Linux) 6 | 7 | ``` 8 | brew install tilt-dev/tap/ctlptl 9 | ``` 10 | 11 | ### Scoop (Windows) 12 | 13 | ``` 14 | scoop bucket add tilt-dev https://github.com/tilt-dev/scoop-bucket 15 | scoop install ctlptl 16 | ``` 17 | 18 | ## Alternative 19 | 20 | ### Docker 21 | 22 | Available on Docker Hub as [`tiltdev/ctlptl`](https://hub.docker.com/r/tiltdev/ctlptl/tags) 23 | 24 | Contains the most recent version of `kind` and `ctlptl` for use in CI environments. 25 | 26 | ### Point and click 27 | 28 | Visit [the releases page](https://github.com/tilt-dev/ctlptl/releases) and 29 | download the pre-build binaries for your architecture. 30 | 31 | ### Go install 32 | 33 | For global installation with go use the following command: 34 | ```bash 35 | go install github.com/tilt-dev/ctlptl/cmd/ctlptl@latest 36 | ``` 37 | 38 | ### Command-line 39 | 40 | On macOS: 41 | 42 | ```bash 43 | CTLPTL_VERSION="0.8.44" 44 | curl -fsSL https://github.com/tilt-dev/ctlptl/releases/download/v$CTLPTL_VERSION/ctlptl.$CTLPTL_VERSION.mac.x86_64.tar.gz | sudo tar -xzv -C /usr/local/bin ctlptl 45 | ``` 46 | 47 | On Linux: 48 | 49 | ```bash 50 | CTLPTL_VERSION="0.8.44" 51 | curl -fsSL https://github.com/tilt-dev/ctlptl/releases/download/v$CTLPTL_VERSION/ctlptl.$CTLPTL_VERSION.linux.x86_64.tar.gz | sudo tar -xzv -C /usr/local/bin ctlptl 52 | ``` 53 | 54 | On Windows: 55 | 56 | ```powershell 57 | $CTLPTL_VERSION = "0.8.44" 58 | Invoke-WebRequest "https://github.com/tilt-dev/ctlptl/releases/download/v$CTLPTL_VERSION/ctlptl.$CTLPTL_VERSION.windows.x86_64.zip" -OutFile "ctlptl.zip" 59 | Expand-Archive "ctlptl.zip" -DestinationPath "ctlptl" 60 | Move-Item -Force -Path "ctlptl\ctlptl.exe" -Destination "$home\bin\ctlptl.exe" 61 | ``` 62 | -------------------------------------------------------------------------------- /.circleci/Dockerfile: -------------------------------------------------------------------------------- 1 | # Builds a Docker image with: 2 | # - ctlptl 3 | # - docker 4 | # - kubectl 5 | # - kind 6 | # - socat 7 | # - golang build toolchain 8 | # 9 | # Similar to the release image (which contains everything BUT the build 10 | # toolchain) 11 | 12 | FROM golang:1.24-bookworm 13 | 14 | RUN apt update && apt install -y curl ca-certificates liblz4-tool rsync socat gpg 15 | 16 | # Install docker CLI 17 | RUN set -exu \ 18 | # Add Docker's official GPG key: 19 | && install -m 0755 -d /etc/apt/keyrings \ 20 | && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ 21 | && chmod a+r /etc/apt/keyrings/docker.asc \ 22 | # Add the repository to Apt sources: 23 | && echo \ 24 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ 25 | $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ 26 | tee /etc/apt/sources.list.d/docker.list > /dev/null \ 27 | && apt update \ 28 | && apt install -y docker-ce-cli=5:25.0.3-1~debian.12~bookworm docker-buildx-plugin 29 | 30 | # Install kubectl client 31 | ENV KUBECTL_VERSION=v1.31.0 32 | RUN curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \ 33 | && curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" \ 34 | && echo "$(cat kubectl.sha256) kubectl" | sha256sum --check \ 35 | && install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl 36 | 37 | # install Kind 38 | ENV KIND_VERSION=v0.31.0 39 | RUN set -exu \ 40 | && curl -fLo ./kind-linux-amd64 "https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64" \ 41 | && chmod +x ./kind-linux-amd64 \ 42 | && mv ./kind-linux-amd64 /usr/local/bin/kind 43 | 44 | -------------------------------------------------------------------------------- /pkg/api/schema.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | runtime "k8s.io/apimachinery/pkg/runtime" 5 | "k8s.io/apimachinery/pkg/runtime/schema" 6 | ) 7 | 8 | func (obj *Cluster) GetObjectKind() schema.ObjectKind { return obj } 9 | func (obj *Cluster) SetGroupVersionKind(gvk schema.GroupVersionKind) { 10 | obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() 11 | } 12 | func (obj *Cluster) GroupVersionKind() schema.GroupVersionKind { 13 | return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) 14 | } 15 | 16 | var _ runtime.Object = &Cluster{} 17 | 18 | func (obj *ClusterList) GetObjectKind() schema.ObjectKind { return obj } 19 | func (obj *ClusterList) SetGroupVersionKind(gvk schema.GroupVersionKind) { 20 | obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() 21 | } 22 | func (obj *ClusterList) GroupVersionKind() schema.GroupVersionKind { 23 | return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) 24 | } 25 | 26 | var _ runtime.Object = &ClusterList{} 27 | 28 | func (obj *Registry) GetObjectKind() schema.ObjectKind { return obj } 29 | func (obj *Registry) SetGroupVersionKind(gvk schema.GroupVersionKind) { 30 | obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() 31 | } 32 | func (obj *Registry) GroupVersionKind() schema.GroupVersionKind { 33 | return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) 34 | } 35 | 36 | var _ runtime.Object = &Registry{} 37 | 38 | func (obj *RegistryList) GetObjectKind() schema.ObjectKind { return obj } 39 | func (obj *RegistryList) SetGroupVersionKind(gvk schema.GroupVersionKind) { 40 | obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() 41 | } 42 | func (obj *RegistryList) GroupVersionKind() schema.GroupVersionKind { 43 | return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) 44 | } 45 | 46 | var _ runtime.Object = &RegistryList{} 47 | -------------------------------------------------------------------------------- /pkg/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/pkg/errors" 10 | "gopkg.in/yaml.v3" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | 13 | "github.com/tilt-dev/ctlptl/pkg/api" 14 | ) 15 | 16 | // Parses a stream of YAML. 17 | func ParseStream(r io.Reader) ([]runtime.Object, error) { 18 | var current bytes.Buffer 19 | reader := io.TeeReader(bufio.NewReader(r), ¤t) 20 | 21 | objDecoder := yaml.NewDecoder(¤t) 22 | objDecoder.KnownFields(true) 23 | 24 | typeDecoder := yaml.NewDecoder(reader) 25 | result := []runtime.Object{} 26 | for { 27 | tm := api.TypeMeta{} 28 | if err := typeDecoder.Decode(&tm); err != nil { 29 | if err == io.EOF { 30 | break 31 | } 32 | return nil, err 33 | } 34 | 35 | obj, err := determineObj(tm) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | if err := objDecoder.Decode(obj); err != nil { 41 | if err == io.EOF { 42 | break 43 | } 44 | return nil, errors.Wrapf(err, "decoding %s", tm) 45 | } 46 | 47 | result = append(result, obj) 48 | } 49 | return result, nil 50 | } 51 | 52 | // Determines the object corresponding to this type meta 53 | func determineObj(tm api.TypeMeta) (runtime.Object, error) { 54 | // decode specific (apiVersion, kind) 55 | switch tm.APIVersion { 56 | // Currently we only support ctlptl.dev/v1alpha1 57 | case "ctlptl.dev/v1alpha1": 58 | switch tm.Kind { 59 | case "Cluster": 60 | return &api.Cluster{}, nil 61 | case "Registry": 62 | return &api.Registry{}, nil 63 | default: 64 | return nil, fmt.Errorf("ctlptl config must contain: `kind: Cluster` or `kind: Registry`") 65 | } 66 | default: 67 | return nil, fmt.Errorf("ctlptl config must contain: `apiVersion: ctlptl.dev/v1alpha1`") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pkg/cmd/create_cluster_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | apierrors "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | 13 | "github.com/tilt-dev/ctlptl/pkg/api" 14 | ) 15 | 16 | func TestCreateCluster(t *testing.T) { 17 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 18 | o := NewCreateClusterOptions() 19 | o.IOStreams = streams 20 | 21 | fcc := &fakeClusterController{} 22 | err := o.run(fcc, "kind") 23 | require.NoError(t, err) 24 | assert.Equal(t, "cluster.ctlptl.dev/kind-kind created\n", out.String()) 25 | assert.Equal(t, "kind-kind", fcc.lastApplyName) 26 | } 27 | 28 | type fakeClusterController struct { 29 | clusters map[string]*api.Cluster 30 | lastApplyName string 31 | lastDeleteName string 32 | nextError error 33 | } 34 | 35 | func (cd *fakeClusterController) Delete(ctx context.Context, name string) error { 36 | if cd.nextError != nil { 37 | return cd.nextError 38 | } 39 | cd.lastDeleteName = name 40 | delete(cd.clusters, name) 41 | return nil 42 | } 43 | 44 | func (cd *fakeClusterController) Apply(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { 45 | cd.lastApplyName = cluster.Name 46 | if cd.clusters == nil { 47 | cd.clusters = make(map[string]*api.Cluster) 48 | } 49 | cd.clusters[cluster.Name] = cluster 50 | return cluster, nil 51 | } 52 | 53 | func (cd *fakeClusterController) Get(ctx context.Context, name string) (*api.Cluster, error) { 54 | cluster, ok := cd.clusters[name] 55 | if ok { 56 | return cluster, nil 57 | } 58 | return nil, apierrors.NewNotFound(schema.GroupResource{Group: "ctlptl.dev", Resource: "clusters"}, name) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cluster/docker.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "regexp" 7 | 8 | "github.com/tilt-dev/ctlptl/internal/dctr" 9 | ) 10 | 11 | const ( 12 | shortLen = 12 13 | ) 14 | 15 | var ( 16 | validShortID = regexp.MustCompile("^[a-f0-9]{12}$") 17 | ) 18 | 19 | // IsShortID determines if id has the correct format and length for a short ID. 20 | // It checks the IDs length and if it consists of valid characters for IDs (a-f0-9). 21 | // 22 | // Deprecated: this function is no longer used, and will be removed in the next release. 23 | func isShortID(id string) bool { 24 | if len(id) != shortLen { 25 | return false 26 | } 27 | return validShortID.MatchString(id) 28 | } 29 | 30 | type detectInContainer interface { 31 | insideContainer(ctx context.Context) string 32 | } 33 | 34 | // InsideContainer checks the current host and docker client to see if we are 35 | // running inside a container with a Docker-out-of-Docker-mounted socket. It 36 | // checks if: 37 | // 38 | // - The effective DOCKER_HOST is `/var/run/docker.sock` 39 | // - The hostname looks like a container "short id" and is a valid, running 40 | // container 41 | // 42 | // Returns a non-empty string representing the container ID if inside a container. 43 | func insideContainer(ctx context.Context, client dctr.Client) string { 44 | // allows fake client to mock the result 45 | if detect, ok := client.(detectInContainer); ok { 46 | return detect.insideContainer(ctx) 47 | } 48 | 49 | if client.DaemonHost() != "unix:///var/run/docker.sock" { 50 | return "" 51 | } 52 | 53 | containerID, err := os.Hostname() 54 | if err != nil { 55 | return "" 56 | } 57 | 58 | if !isShortID(containerID) { 59 | return "" 60 | } 61 | 62 | container, err := client.ContainerInspect(ctx, containerID) 63 | if err != nil { 64 | return "" 65 | } 66 | 67 | if !container.State.Running { 68 | return "" 69 | } 70 | 71 | return containerID 72 | } 73 | -------------------------------------------------------------------------------- /pkg/visitor/visitor.go: -------------------------------------------------------------------------------- 1 | package visitor 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | ) 9 | 10 | // A simplified version of cli-runtime/pkg/resource Visitor 11 | // for objects that don't query the cluster. 12 | type Interface interface { 13 | Name() string 14 | Open() (io.ReadCloser, error) 15 | } 16 | 17 | func Stdin(stdin io.Reader) stdinVisitor { 18 | return stdinVisitor{reader: stdin} 19 | } 20 | 21 | type noOpCloseReader struct { 22 | io.Reader 23 | } 24 | 25 | func (noOpCloseReader) Close() error { return nil } 26 | 27 | type stdinVisitor struct { 28 | reader io.Reader 29 | } 30 | 31 | func (v stdinVisitor) Name() string { 32 | return "stdin" 33 | } 34 | 35 | func (v stdinVisitor) Open() (io.ReadCloser, error) { 36 | return noOpCloseReader{Reader: v.reader}, nil 37 | } 38 | 39 | var _ Interface = stdinVisitor{} 40 | 41 | func File(path string) fileVisitor { 42 | return fileVisitor{path: path} 43 | } 44 | 45 | type fileVisitor struct { 46 | path string 47 | } 48 | 49 | func (v fileVisitor) Name() string { 50 | return v.path 51 | } 52 | 53 | func (v fileVisitor) Open() (io.ReadCloser, error) { 54 | return os.Open(v.path) 55 | } 56 | 57 | var _ Interface = urlVisitor{} 58 | 59 | type HTTPClient interface { 60 | Get(url string) (*http.Response, error) 61 | } 62 | 63 | func URL(client HTTPClient, url string) urlVisitor { 64 | return urlVisitor{client: client, url: url} 65 | } 66 | 67 | type urlVisitor struct { 68 | client HTTPClient 69 | url string 70 | } 71 | 72 | func (v urlVisitor) Name() string { 73 | return v.url 74 | } 75 | 76 | func (v urlVisitor) Open() (io.ReadCloser, error) { 77 | resp, err := v.client.Get(v.url) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if resp.StatusCode != http.StatusOK { 82 | return nil, fmt.Errorf("fetch(%q) failed with status code %d", v.url, resp.StatusCode) 83 | } 84 | return resp.Body, nil 85 | } 86 | 87 | var _ Interface = urlVisitor{} 88 | -------------------------------------------------------------------------------- /hack/Dockerfile: -------------------------------------------------------------------------------- 1 | # Builds a Docker image with: 2 | # - ctlptl 3 | # - docker 4 | # - kubectl 5 | # - kind 6 | # - socat 7 | # 8 | # Good base image for anyone that wants to use ctlptl in a CI environment 9 | # to set up a one-time-use cluster. 10 | # 11 | # Built with goreleaser. 12 | 13 | FROM debian:bookworm-slim 14 | 15 | RUN apt update && apt install -y curl ca-certificates liblz4-tool rsync socat 16 | 17 | # Install docker CLI 18 | RUN set -exu \ 19 | # Add Docker's official GPG key: 20 | && install -m 0755 -d /etc/apt/keyrings \ 21 | && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ 22 | && chmod a+r /etc/apt/keyrings/docker.asc \ 23 | # Add the repository to Apt sources: 24 | && echo \ 25 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ 26 | $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ 27 | tee /etc/apt/sources.list.d/docker.list > /dev/null \ 28 | && apt update \ 29 | && apt install -y docker-ce-cli=5:25.0.3-1~debian.12~bookworm 30 | 31 | # Install kubectl client 32 | ARG TARGETARCH 33 | ENV KUBECTL_VERSION=v1.29.1 34 | RUN curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" \ 35 | && curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl.sha256" \ 36 | && echo "$(cat kubectl.sha256) kubectl" | sha256sum --check \ 37 | && install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl 38 | 39 | # Install Kind 40 | ENV KIND_VERSION=v0.31.0 41 | RUN set -exu \ 42 | && KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-$TARGETARCH" \ 43 | && curl --silent --show-error --location --fail --retry 3 --output ./kind-linux-$TARGETARCH "$KIND_URL" \ 44 | && chmod +x ./kind-linux-$TARGETARCH \ 45 | && mv ./kind-linux-$TARGETARCH /usr/local/bin/kind 46 | 47 | COPY ctlptl /usr/local/bin/ctlptl 48 | -------------------------------------------------------------------------------- /docs/ctlptl_get.md: -------------------------------------------------------------------------------- 1 | ## ctlptl get 2 | 3 | Read currently running clusters and registries 4 | 5 | ### Synopsis 6 | 7 | Read the status of currently running clusters and registries. 8 | 9 | Supports the same flags as kubectl for selecting 10 | and printing fields. The kubectl cheat sheet may help: 11 | 12 | https://kubernetes.io/docs/reference/kubectl/cheatsheet/#formatting-output 13 | 14 | 15 | ``` 16 | ctlptl get [type] [name] [flags] 17 | ``` 18 | 19 | ### Examples 20 | 21 | ``` 22 | ctlptl get 23 | ctlptl get cluster microk8s -o yaml 24 | ctlptl get cluster kind-kind -o template --template '{{.status.localRegistryHosting.host}}' 25 | 26 | ``` 27 | 28 | ### Options 29 | 30 | ``` 31 | --allow-missing-template-keys If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. (default true) 32 | --field-selector string Selector (field query) to filter on, supports '=', '==', and '!='.(e.g. --field-selector key1=value1,key2=value2). The server only supports a limited number of field queries per type. 33 | -h, --help help for get 34 | --ignore-not-found If the requested object does not exist the command will return exit code 0. 35 | -o, --output string Output format. One of: (json, yaml, name, go-template, go-template-file, template, templatefile, jsonpath, jsonpath-as-json, jsonpath-file). 36 | --show-managed-fields If true, keep the managedFields when printing objects in JSON or YAML format. 37 | --template string Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview]. 38 | ``` 39 | 40 | ### SEE ALSO 41 | 42 | * [ctlptl](ctlptl.md) - Mess around with local Kubernetes clusters without consequences 43 | 44 | ###### Auto generated by spf13/cobra on 18-Dec-2025 45 | -------------------------------------------------------------------------------- /internal/exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os/exec" 7 | "strings" 8 | 9 | "k8s.io/cli-runtime/pkg/genericclioptions" 10 | ) 11 | 12 | // A dummy package to help with mocking out exec.NewCommand 13 | 14 | type CmdRunner interface { 15 | Run(ctx context.Context, cmd string, args ...string) error 16 | RunIO(ctx context.Context, iostreams genericclioptions.IOStreams, cmd string, args ...string) error 17 | } 18 | 19 | type RealCmdRunner struct{} 20 | 21 | func (RealCmdRunner) Run(ctx context.Context, cmd string, args ...string) error { 22 | // For some reason, ExitError only gets populated with Stderr if we call Output(). 23 | _, err := exec.CommandContext(ctx, cmd, args...).Output() 24 | 25 | return err 26 | } 27 | 28 | func (RealCmdRunner) RunIO(ctx context.Context, iostreams genericclioptions.IOStreams, cmd string, args ...string) error { 29 | c := exec.CommandContext(ctx, cmd, args...) 30 | c.Stdin = iostreams.In 31 | c.Stderr = iostreams.ErrOut 32 | c.Stdout = iostreams.Out 33 | return c.Run() 34 | } 35 | 36 | type FakeCmdRunner struct { 37 | handler func(argv []string) string 38 | LastArgs []string 39 | LastStdin string 40 | } 41 | 42 | func NewFakeCmdRunner(handler func(argv []string) string) *FakeCmdRunner { 43 | return &FakeCmdRunner{handler: handler} 44 | } 45 | 46 | func (f *FakeCmdRunner) Run(ctx context.Context, cmd string, args ...string) error { 47 | f.LastArgs = append([]string{cmd}, args...) 48 | _ = f.handler(append([]string{cmd}, args...)) 49 | return nil 50 | } 51 | 52 | func (f *FakeCmdRunner) RunIO(ctx context.Context, iostreams genericclioptions.IOStreams, cmd string, args ...string) error { 53 | f.LastArgs = append([]string{cmd}, args...) 54 | 55 | if iostreams.In != nil { 56 | in, err := io.ReadAll(iostreams.In) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | f.LastStdin = string(in) 62 | } else { 63 | f.LastStdin = "" 64 | } 65 | 66 | out := f.handler(append([]string{cmd}, args...)) 67 | _, err := io.Copy(iostreams.Out, strings.NewReader(out)) 68 | return err 69 | } 70 | -------------------------------------------------------------------------------- /docs/ctlptl_create_cluster.md: -------------------------------------------------------------------------------- 1 | ## ctlptl create cluster 2 | 3 | Create a cluster with the given local Kubernetes product 4 | 5 | ``` 6 | ctlptl create cluster [product] [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | ctlptl create cluster docker-desktop 13 | ctlptl create cluster kind --registry=ctlptl-registry 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | --allow-missing-template-keys If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. (default true) 20 | -h, --help help for cluster 21 | --kubernetes-version string Sets the kubernetes version for the cluster, if possible 22 | --min-cpus int Sets the minimum CPUs for the cluster 23 | --minikube-container-runtime string Minikube container runtime (only applicable to a minikube cluster) 24 | --minikube-extra-configs strings Minikube extra configs (only applicable to a minikube cluster) 25 | --minikube-start-flags strings Minikube extra start flags (only applicable to a minikube cluster) 26 | --name string Names the context. If not specified, uses the default cluster name for this Kubernetes product 27 | -o, --output string Output format. One of: (json, yaml, name, go-template, go-template-file, template, templatefile, jsonpath, jsonpath-as-json, jsonpath-file). 28 | --registry string Connect the cluster to the named registry 29 | --show-managed-fields If true, keep the managedFields when printing objects in JSON or YAML format. 30 | --template string Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview]. 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [ctlptl create](ctlptl_create.md) - Create a cluster or registry 36 | 37 | ###### Auto generated by spf13/cobra on 18-Dec-2025 38 | -------------------------------------------------------------------------------- /test/k3d/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tests creating a cluster with a registry, 3 | # building a container in that cluster, 4 | # then running that container. 5 | 6 | set -exo pipefail 7 | 8 | export DOCKER_BUILDKIT="1" 9 | 10 | cd $(dirname $(realpath $0)) 11 | CLUSTER_NAME="k3d-ctlptl-test-cluster" 12 | ctlptl apply -f registry.yaml 13 | ctlptl apply -f cluster.yaml 14 | 15 | # The ko-builder runs in an image tagged with the host as visible from the local machine. 16 | docker buildx build --load -t localhost:5005/ko-builder . 17 | docker push localhost:5005/ko-builder 18 | 19 | # The ko-builder builds an image tagged with the host as visible from the cluster network. 20 | HOST_FROM_CONTAINER_RUNTIME=$(ctlptl get cluster "$CLUSTER_NAME" -o template --template '{{.status.localRegistryHosting.hostFromContainerRuntime}}') 21 | HOST_FROM_CLUSTER_NETWORK=$(ctlptl get cluster "$CLUSTER_NAME" -o template --template '{{.status.localRegistryHosting.hostFromClusterNetwork}}') 22 | cat builder.yaml | \ 23 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CONTAINER_RUNTIME/g" | \ 24 | sed "s/HOST_FROM_CLUSTER_NETWORK/$HOST_FROM_CLUSTER_NETWORK/g" | \ 25 | kubectl apply -f - 26 | 27 | set +e 28 | kubectl wait --for=condition=complete job/ko-builder --timeout=180s 29 | RESULT="$?" 30 | set -e 31 | 32 | if [[ "$RESULT" != "0" ]]; then 33 | echo "ko-builder never became healthy" 34 | kubectl describe pods -l app=ko-builder 35 | kubectl logs -l app=ko-builder --all-containers 36 | exit 1 37 | fi 38 | 39 | # Test registry from both localhost and the connected network. 40 | cat simple-server.yaml | \ 41 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CONTAINER_RUNTIME/g" | \ 42 | kubectl apply -f - 43 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=60s 44 | kubectl delete deployment simple-server 45 | 46 | cat simple-server.yaml | \ 47 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CLUSTER_NETWORK/g" | \ 48 | kubectl apply -f - 49 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=60s 50 | 51 | ctlptl delete -f cluster.yaml 52 | 53 | echo "k3d e2e test passed!" 54 | -------------------------------------------------------------------------------- /pkg/docker/docker.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | const ContainerLabelRole = "dev.tilt.ctlptl.role" 8 | 9 | // Checks whether the Docker daemon is running on a local machine. 10 | // Remote docker daemons will likely need a port forwarder to work properly. 11 | func IsLocalHost(dockerHost string) bool { 12 | return dockerHost == "" || 13 | 14 | // Check all the "standard" docker localhosts. 15 | // https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/opts/hosts.go#L22 16 | strings.HasPrefix(dockerHost, "tcp://localhost:") || 17 | strings.HasPrefix(dockerHost, "tcp://127.0.0.1:") || 18 | 19 | // https://github.com/moby/moby/blob/master/client/client_windows.go#L4 20 | strings.HasPrefix(dockerHost, "npipe:") || 21 | 22 | // https://github.com/moby/moby/blob/master/client/client_unix.go#L6 23 | strings.HasPrefix(dockerHost, "unix:") 24 | } 25 | 26 | // Checks whether the DOCKER_HOST looks like a local Docker Engine. 27 | func IsLocalDockerEngineHost(dockerHost string) bool { 28 | if strings.HasPrefix(dockerHost, "unix:") { 29 | // Many tools (like colima) try to masquerade as Docker Desktop but run 30 | // on a different socket. 31 | // see: 32 | // https://github.com/tilt-dev/ctlptl/issues/196 33 | // https://docs.docker.com/desktop/faqs/#how-do-i-connect-to-the-remote-docker-engine-api 34 | return strings.Contains(dockerHost, "/var/run/docker.sock") || 35 | // Docker Desktop for Linux - socket is in ~/.docker/desktop/docker.sock 36 | strings.HasSuffix(dockerHost, "/.docker/desktop/docker.sock") || 37 | // Docker Desktop for Mac 4.13+ - socket is in ~/.docker/run/docker.sock 38 | strings.HasSuffix(dockerHost, "/.docker/run/docker.sock") 39 | } 40 | 41 | // Docker daemons on other local protocols are treated as docker desktop. 42 | return IsLocalHost(dockerHost) 43 | } 44 | 45 | // Checks whether the DOCKER_HOST looks like a local Docker Desktop. 46 | // A local Docker Engine has some additional APIs for VM management (i.e., Docker Desktop). 47 | func IsLocalDockerDesktop(dockerHost string, os string) bool { 48 | if os == "darwin" || os == "windows" { 49 | return IsLocalDockerEngineHost(dockerHost) 50 | } 51 | return strings.HasPrefix(dockerHost, "unix:") && 52 | strings.HasSuffix(dockerHost, "/.docker/desktop/docker.sock") 53 | } 54 | -------------------------------------------------------------------------------- /pkg/cluster/admin_docker_desktop.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/tilt-dev/localregistry-go" 8 | 9 | "github.com/tilt-dev/ctlptl/pkg/api" 10 | "github.com/tilt-dev/ctlptl/pkg/docker" 11 | ) 12 | 13 | // The DockerDesktop manages the Kubernetes cluster for DockerDesktop. 14 | // This is a bit different than the other admins, due to the overlap 15 | type dockerDesktopAdmin struct { 16 | os string 17 | host string 18 | client d4mClient 19 | } 20 | 21 | func newDockerDesktopAdmin(host string, os string, d4m d4mClient) *dockerDesktopAdmin { 22 | return &dockerDesktopAdmin{os: os, host: host, client: d4m} 23 | } 24 | 25 | func (a *dockerDesktopAdmin) EnsureInstalled(ctx context.Context) error { return nil } 26 | func (a *dockerDesktopAdmin) Create(ctx context.Context, desired *api.Cluster, registry *api.Registry) error { 27 | if registry != nil { 28 | return fmt.Errorf("ctlptl currently does not support connecting a registry to docker-desktop") 29 | } 30 | if len(desired.RegistryAuths) > 0 { 31 | return fmt.Errorf("ctlptl currently does not support connecting pull-through registries to docker-desktop") 32 | } 33 | 34 | isLocalDockerDesktop := docker.IsLocalDockerDesktop(a.host, a.os) 35 | if !isLocalDockerDesktop { 36 | return fmt.Errorf("docker-desktop clusters are only available on a local Docker Desktop. Current DOCKER_HOST: %s", 37 | a.host) 38 | } 39 | 40 | err := a.client.ResetCluster(ctx) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (a *dockerDesktopAdmin) LocalRegistryHosting(ctx context.Context, desired *api.Cluster, registry *api.Registry) (*localregistry.LocalRegistryHostingV1, error) { 49 | return nil, nil 50 | } 51 | 52 | func (a *dockerDesktopAdmin) Delete(ctx context.Context, config *api.Cluster) error { 53 | isLocalDockerHost := docker.IsLocalDockerDesktop(a.host, a.os) 54 | if !isLocalDockerHost { 55 | return fmt.Errorf("docker-desktop cannot be deleted from DOCKER_HOST: %s", a.host) 56 | } 57 | 58 | settings, err := a.client.settings(ctx) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | changed, err := a.client.setK8sEnabled(settings, false) 64 | if err != nil { 65 | return err 66 | } 67 | if !changed { 68 | return nil 69 | } 70 | 71 | return a.client.writeSettings(ctx, settings) 72 | } 73 | -------------------------------------------------------------------------------- /test/kind/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tests creating a cluster with a registry, 3 | # building a container in that cluster, 4 | # then running that container. 5 | 6 | set -exo pipefail 7 | 8 | export DOCKER_BUILDKIT="1" 9 | 10 | cd $(dirname $(realpath $0)) 11 | CLUSTER_NAME="kind-ctlptl-test-cluster" 12 | ctlptl apply -f registry.yaml 13 | ctlptl apply -f cluster.yaml 14 | 15 | # The ko-builder runs in an image tagged with the host as visible from the local machine. 16 | docker buildx build --load -t localhost:5005/ko-builder . 17 | docker push localhost:5005/ko-builder 18 | 19 | # The ko-builder builds an image tagged with the host as visible from the cluster network. 20 | HOST_FROM_CONTAINER_RUNTIME=$(ctlptl get cluster "$CLUSTER_NAME" -o template --template '{{.status.localRegistryHosting.host}}') 21 | HOST_FROM_CLUSTER_NETWORK=$(ctlptl get cluster "$CLUSTER_NAME" -o template --template '{{.status.localRegistryHosting.hostFromClusterNetwork}}') 22 | cat builder.yaml | \ 23 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CONTAINER_RUNTIME/g" | \ 24 | sed "s/HOST_FROM_CLUSTER_NETWORK/$HOST_FROM_CLUSTER_NETWORK/g" | \ 25 | kubectl apply -f - 26 | 27 | set +e 28 | kubectl wait --for=condition=complete job/ko-builder --timeout=180s 29 | RESULT="$?" 30 | set -e 31 | 32 | if [[ "$RESULT" != "0" ]]; then 33 | echo "ko-builder never became healthy" 34 | kubectl describe pods -l app=ko-builder 35 | kubectl logs -l app=ko-builder --all-containers 36 | exit 1 37 | fi 38 | 39 | # Test registry from both localhost and the connected network. 40 | cat simple-server.yaml | \ 41 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CONTAINER_RUNTIME/g" | \ 42 | kubectl apply -f - 43 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=60s 44 | kubectl delete deployment simple-server 45 | 46 | cat simple-server.yaml | \ 47 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CLUSTER_NETWORK/g" | \ 48 | kubectl apply -f - 49 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=60s 50 | 51 | # Check to see we started the right kubernetes version. 52 | k8sVersion=$(ctlptl get cluster "$CLUSTER_NAME" -o go-template --template='{{.status.kubernetesVersion}}') 53 | 54 | ctlptl delete -f cluster.yaml 55 | 56 | if [[ "$k8sVersion" != "v1.34.3" ]]; then 57 | echo "Expected kubernetes version v1.34.3 but got $k8sVersion" 58 | exit 1 59 | fi 60 | 61 | echo "kind e2e test passed!" 62 | -------------------------------------------------------------------------------- /test/minikube/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tests creating a cluster with a registry, 3 | # building a container in that cluster, 4 | # then running that container. 5 | 6 | set -exo pipefail 7 | 8 | export DOCKER_BUILDKIT="1" 9 | 10 | cd $(dirname $(realpath $0)) 11 | CLUSTER_NAME="minikube-ctlptl-test-cluster" 12 | ctlptl apply -f registry.yaml 13 | ctlptl apply -f cluster.yaml 14 | 15 | # The ko-builder runs in an image tagged with the host as visible from the local machine. 16 | docker buildx build --load -t localhost:5005/ko-builder . 17 | docker push localhost:5005/ko-builder 18 | 19 | # The ko-builder builds an image tagged with the host as visible from the cluster network. 20 | HOST_FROM_CONTAINER_RUNTIME=$(ctlptl get cluster "$CLUSTER_NAME" -o template --template '{{.status.localRegistryHosting.host}}') 21 | HOST_FROM_CLUSTER_NETWORK=$(ctlptl get cluster "$CLUSTER_NAME" -o template --template '{{.status.localRegistryHosting.hostFromClusterNetwork}}') 22 | cat builder.yaml | \ 23 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CONTAINER_RUNTIME/g" | \ 24 | sed "s/HOST_FROM_CLUSTER_NETWORK/$HOST_FROM_CLUSTER_NETWORK/g" | \ 25 | kubectl apply -f - 26 | 27 | set +e 28 | kubectl wait --for=condition=complete job/ko-builder --timeout=180s 29 | RESULT="$?" 30 | set -e 31 | 32 | if [[ "$RESULT" != "0" ]]; then 33 | echo "ko-builder never became healthy" 34 | kubectl describe pods -l app=ko-builder 35 | kubectl logs -l app=ko-builder --all-containers 36 | exit 1 37 | fi 38 | 39 | # Test registry from both localhost and the connected network. 40 | cat simple-server.yaml | \ 41 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CONTAINER_RUNTIME/g" | \ 42 | kubectl apply -f - 43 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=60s 44 | kubectl delete deployment simple-server 45 | 46 | cat simple-server.yaml | \ 47 | sed "s/HOST_FROM_CONTAINER_RUNTIME/$HOST_FROM_CLUSTER_NETWORK/g" | \ 48 | kubectl apply -f - 49 | kubectl wait --for=condition=ready pods -l app=simple-server --timeout=60s 50 | 51 | # Check to see we started the right kubernetes version. 52 | k8sVersion=$(ctlptl get cluster "$CLUSTER_NAME" -o go-template --template='{{.status.kubernetesVersion}}') 53 | 54 | ctlptl delete -f cluster.yaml 55 | 56 | if [[ "$k8sVersion" != "v1.31.0" ]]; then 57 | echo "Expected kubernetes version v1.31.0 but got $k8sVersion" 58 | exit 1 59 | fi 60 | 61 | echo "minikube e2e test passed!" 62 | -------------------------------------------------------------------------------- /pkg/docker/docker_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type dockerHostTestCase struct { 11 | host string 12 | localDaemon bool 13 | dockerDesktop bool 14 | } 15 | 16 | func TestIsLocalDockerHost(t *testing.T) { 17 | cases := []dockerHostTestCase{ 18 | dockerHostTestCase{"", true, true}, 19 | dockerHostTestCase{"tcp://localhost:2375", true, true}, 20 | dockerHostTestCase{"tcp://127.0.0.1:2375", true, true}, 21 | dockerHostTestCase{"npipe:////./pipe/docker_engine", true, true}, 22 | dockerHostTestCase{"unix:///var/run/docker.sock", true, true}, 23 | dockerHostTestCase{"tcp://cluster:2375", false, false}, 24 | dockerHostTestCase{"http://cluster:2375", false, false}, 25 | dockerHostTestCase{"unix:///Users/USER/.colima/docker.sock", true, false}, 26 | dockerHostTestCase{"unix:///Users/USER/.docker/desktop/docker.sock", true, true}, 27 | dockerHostTestCase{"unix:///Users/USER/.docker/run/docker.sock", true, true}, 28 | } 29 | for i, c := range cases { 30 | c := c 31 | t.Run(fmt.Sprintf("%s-%d", t.Name(), i), func(t *testing.T) { 32 | assert.Equal(t, c.localDaemon, IsLocalHost(c.host)) 33 | assert.Equal(t, c.dockerDesktop, IsLocalDockerEngineHost(c.host)) 34 | }) 35 | } 36 | } 37 | 38 | type dockerDesktopTestCase struct { 39 | host string 40 | os string 41 | expected bool 42 | } 43 | 44 | func TestIsLocalDockerDesktop(t *testing.T) { 45 | cases := []dockerDesktopTestCase{ 46 | dockerDesktopTestCase{"", "linux", false}, 47 | dockerDesktopTestCase{"tcp://localhost:2375", "linux", false}, 48 | dockerDesktopTestCase{"tcp://127.0.0.1:2375", "linux", false}, 49 | dockerDesktopTestCase{"npipe:////./pipe/docker_engine", "windows", true}, 50 | dockerDesktopTestCase{"unix:///var/run/docker.sock", "darwin", true}, 51 | dockerDesktopTestCase{"unix:///var/run/docker.sock", "linux", false}, 52 | dockerDesktopTestCase{"tcp://cluster:2375", "linux", false}, 53 | dockerDesktopTestCase{"http://cluster:2375", "linux", false}, 54 | dockerDesktopTestCase{"unix:///Users/USER/.colima/docker.sock", "linux", false}, 55 | dockerDesktopTestCase{"unix:///Users/USER/.docker/desktop/docker.sock", "linux", true}, 56 | } 57 | for i, c := range cases { 58 | c := c 59 | t.Run(fmt.Sprintf("%s-%d", t.Name(), i), func(t *testing.T) { 60 | assert.Equal(t, c.expected, IsLocalDockerDesktop(c.host, c.os)) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/cmd/create_registry.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | 13 | "github.com/tilt-dev/ctlptl/pkg/api" 14 | "github.com/tilt-dev/ctlptl/pkg/registry" 15 | ) 16 | 17 | type CreateRegistryOptions struct { 18 | *genericclioptions.PrintFlags 19 | genericclioptions.IOStreams 20 | 21 | Registry *api.Registry 22 | } 23 | 24 | func NewCreateRegistryOptions() *CreateRegistryOptions { 25 | o := &CreateRegistryOptions{ 26 | PrintFlags: genericclioptions.NewPrintFlags("created"), 27 | IOStreams: genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr, In: os.Stdin}, 28 | Registry: &api.Registry{ 29 | TypeMeta: registry.TypeMeta(), 30 | }, 31 | } 32 | return o 33 | } 34 | 35 | func (o *CreateRegistryOptions) Command() *cobra.Command { 36 | var cmd = &cobra.Command{ 37 | Use: "registry [name]", 38 | Short: "Create a registry with the given name", 39 | Example: " ctlptl create registry ctlptl-registry\n" + 40 | " ctlptl create registry ctlptl-registry --port=5000\n" + 41 | " ctlptl create registry ctlptl-registry --port=5000 --listen-address 0.0.0.0", 42 | Run: o.Run, 43 | Args: cobra.ExactArgs(1), 44 | } 45 | 46 | cmd.SetOut(o.Out) 47 | cmd.SetErr(o.ErrOut) 48 | o.AddFlags(cmd) 49 | cmd.Flags().IntVar(&o.Registry.Port, "port", o.Registry.Port, 50 | "The port to expose the registry on host. If not specified, chooses a random port") 51 | cmd.Flags().StringVar(&o.Registry.ListenAddress, "listen-address", o.Registry.ListenAddress, 52 | "The host's IP address to bind the container to. If not set defaults to 127.0.0.1") 53 | cmd.Flags().StringVar(&o.Registry.Image, "image", registry.DefaultRegistryImageRef, 54 | "Registry image to use") 55 | 56 | return cmd 57 | } 58 | 59 | func (o *CreateRegistryOptions) Run(cmd *cobra.Command, args []string) { 60 | controller, err := registry.DefaultController(o.IOStreams) 61 | if err != nil { 62 | _, _ = fmt.Fprintf(o.ErrOut, "%v\n", err) 63 | os.Exit(1) 64 | } 65 | 66 | err = o.run(controller, args[0]) 67 | if err != nil { 68 | _, _ = fmt.Fprintf(o.ErrOut, "%v\n", err) 69 | os.Exit(1) 70 | } 71 | } 72 | 73 | type registryCreator interface { 74 | Apply(ctx context.Context, registry *api.Registry) (*api.Registry, error) 75 | Get(ctx context.Context, name string) (*api.Registry, error) 76 | } 77 | 78 | func (o *CreateRegistryOptions) run(controller registryCreator, name string) error { 79 | a, err := newAnalytics() 80 | if err != nil { 81 | return err 82 | } 83 | a.Incr("cmd.create.registry", nil) 84 | defer a.Flush(time.Second) 85 | 86 | o.Registry.Name = name 87 | registry.FillDefaults(o.Registry) 88 | 89 | ctx := context.Background() 90 | _, err = controller.Get(ctx, o.Registry.Name) 91 | if err == nil { 92 | return fmt.Errorf("Cannot create registry: already exists") 93 | } else if err != nil && !errors.IsNotFound(err) { 94 | return fmt.Errorf("Cannot check registry: %v", err) 95 | } 96 | 97 | applied, err := controller.Apply(ctx, o.Registry) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | printer, err := o.ToPrinter() 103 | if err != nil { 104 | return err 105 | } 106 | 107 | return printer.PrintObj(applied, o.Out) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/cmd/apply.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | "k8s.io/cli-runtime/pkg/genericclioptions" 11 | 12 | "github.com/tilt-dev/ctlptl/pkg/api" 13 | "github.com/tilt-dev/ctlptl/pkg/cluster" 14 | "github.com/tilt-dev/ctlptl/pkg/registry" 15 | "github.com/tilt-dev/ctlptl/pkg/visitor" 16 | ) 17 | 18 | type ApplyOptions struct { 19 | *genericclioptions.PrintFlags 20 | *genericclioptions.FileNameFlags 21 | genericclioptions.IOStreams 22 | 23 | Filenames []string 24 | } 25 | 26 | func NewApplyOptions() *ApplyOptions { 27 | o := &ApplyOptions{ 28 | PrintFlags: genericclioptions.NewPrintFlags("created"), 29 | IOStreams: genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr, In: os.Stdin}, 30 | } 31 | o.FileNameFlags = &genericclioptions.FileNameFlags{Filenames: &o.Filenames} 32 | return o 33 | } 34 | 35 | func (o *ApplyOptions) Command() *cobra.Command { 36 | var cmd = &cobra.Command{ 37 | Use: "apply -f FILENAME", 38 | Short: "Apply a cluster config to the currently running clusters", 39 | Example: " ctlptl apply -f cluster.yaml\n" + 40 | " cat cluster.yaml | ctlptl apply -f -", 41 | Run: o.Run, 42 | } 43 | 44 | cmd.SetOut(o.Out) 45 | cmd.SetErr(o.ErrOut) 46 | o.FileNameFlags.AddFlags(cmd.Flags()) 47 | o.PrintFlags.AddFlags(cmd) 48 | 49 | return cmd 50 | } 51 | 52 | func (o *ApplyOptions) Run(cmd *cobra.Command, args []string) { 53 | if len(o.Filenames) == 0 { 54 | _, _ = fmt.Fprintf(o.ErrOut, "Expected source files with -f") 55 | os.Exit(1) 56 | } 57 | 58 | err := o.run() 59 | if err != nil { 60 | _, _ = fmt.Fprintf(o.ErrOut, "%v\n", err) 61 | os.Exit(1) 62 | } 63 | } 64 | 65 | func (o *ApplyOptions) run() error { 66 | a, err := newAnalytics() 67 | if err != nil { 68 | return err 69 | } 70 | a.Incr("cmd.apply", nil) 71 | defer a.Flush(time.Second) 72 | 73 | ctx := context.TODO() 74 | 75 | printer, err := o.ToPrinter() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | visitors, err := visitor.FromStrings(o.Filenames, o.In) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | objects, err := visitor.DecodeAll(visitors) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | var cc *cluster.Controller 91 | var rc *registry.Controller 92 | for _, obj := range objects { 93 | switch obj := obj.(type) { 94 | case *api.Registry: 95 | if rc == nil { 96 | rc, err = registry.DefaultController(o.IOStreams) 97 | if err != nil { 98 | return err 99 | } 100 | } 101 | 102 | newObj, err := rc.Apply(ctx, obj) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | err = printer.PrintObj(newObj, o.Out) 108 | if err != nil { 109 | return err 110 | } 111 | } 112 | } 113 | 114 | for _, obj := range objects { 115 | switch obj := obj.(type) { 116 | case *api.Cluster: 117 | if cc == nil { 118 | cc, err = cluster.DefaultController(o.IOStreams) 119 | if err != nil { 120 | return err 121 | } 122 | } 123 | 124 | newObj, err := cc.Apply(ctx, obj) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | err = printer.PrintObj(newObj, o.Out) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | case *api.Registry: 135 | // Handled above 136 | continue 137 | 138 | default: 139 | return fmt.Errorf("unrecognized type: %T", obj) 140 | } 141 | } 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/socat/socat.go: -------------------------------------------------------------------------------- 1 | // Manage socat network routers for remote docker instances. 2 | package socat 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/network" 14 | "github.com/shirou/gopsutil/v3/process" 15 | 16 | "github.com/tilt-dev/ctlptl/internal/dctr" 17 | ) 18 | 19 | const serviceName = "ctlptl-portforward-service" 20 | 21 | type Controller struct { 22 | cli dctr.CLI 23 | } 24 | 25 | func NewController(cli dctr.CLI) *Controller { 26 | return &Controller{cli: cli} 27 | } 28 | 29 | // Connect a port on the local machine to a port on a remote docker machine. 30 | func (c *Controller) ConnectRemoteDockerPort(ctx context.Context, port int) error { 31 | err := c.StartRemotePortforwarder(ctx) 32 | if err != nil { 33 | return err 34 | } 35 | return c.StartLocalPortforwarder(ctx, port) 36 | } 37 | 38 | // Create a port-forwarding server on the same machine that's running 39 | // Docker. This server accepts connections and routes them to localhost ports 40 | // on the same machine. 41 | func (c *Controller) StartRemotePortforwarder(ctx context.Context) error { 42 | return dctr.Run( 43 | ctx, 44 | c.cli, 45 | serviceName, 46 | &container.Config{ 47 | Hostname: serviceName, 48 | Image: "alpine/socat", 49 | Entrypoint: []string{"/bin/sh"}, 50 | Cmd: []string{"-c", "while true; do sleep 1000; done"}, 51 | }, 52 | &container.HostConfig{ 53 | NetworkMode: "host", 54 | RestartPolicy: container.RestartPolicy{Name: "always"}, 55 | }, 56 | &network.NetworkingConfig{}) 57 | } 58 | 59 | // Returns the socat process listening on a port, plus its commandline. 60 | func (c *Controller) socatProcessOnPort(port int) (*process.Process, string, error) { 61 | processes, err := process.Processes() 62 | if err != nil { 63 | return nil, "", err 64 | } 65 | for _, p := range processes { 66 | cmdline, err := p.Cmdline() 67 | if err != nil { 68 | continue 69 | } 70 | if strings.HasPrefix(cmdline, fmt.Sprintf("socat TCP-LISTEN:%d,", port)) { 71 | return p, cmdline, nil 72 | } 73 | } 74 | return nil, "", nil 75 | } 76 | 77 | // Create a port-forwarding server on the local machine, forwarding connections 78 | // to the same port on the remote Docker server. 79 | func (c *Controller) StartLocalPortforwarder(ctx context.Context, port int) error { 80 | args := []string{ 81 | fmt.Sprintf("TCP-LISTEN:%d,reuseaddr,fork", port), 82 | fmt.Sprintf("EXEC:'docker exec -i %s socat STDIO TCP:localhost:%d'", serviceName, port), 83 | } 84 | 85 | existing, cmdline, err := c.socatProcessOnPort(port) 86 | if err != nil { 87 | return fmt.Errorf("start portforwarder: %v", err) 88 | } 89 | 90 | if existing != nil { 91 | expectedCmdline := strings.Join(append([]string{"socat"}, args...), " ") 92 | if expectedCmdline == cmdline { 93 | // Already running. 94 | return nil 95 | } 96 | 97 | // Kill and restart. 98 | err := existing.KillWithContext(ctx) 99 | if err != nil { 100 | return fmt.Errorf("start portforwarder: %v", err) 101 | } 102 | } 103 | 104 | cmd := exec.Command("socat", args...) 105 | err = cmd.Start() 106 | if err != nil { 107 | _, err := exec.LookPath("socat") 108 | if err != nil { 109 | return fmt.Errorf("socat not installed: ctlptl requires 'socat' to be installed when setting up clusters on a remote Docker daemon") 110 | } 111 | 112 | return fmt.Errorf("creating local portforwarder: %v", err) 113 | } 114 | 115 | for i := 0; i < 100; i++ { 116 | conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) 117 | if err == nil { 118 | _ = conn.Close() 119 | return nil 120 | } 121 | time.Sleep(100 * time.Millisecond) 122 | } 123 | return fmt.Errorf("timed out waiting for local portforwarder") 124 | } 125 | -------------------------------------------------------------------------------- /pkg/cmd/docker_desktop.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | "gopkg.in/yaml.v3" 11 | 12 | "github.com/tilt-dev/ctlptl/pkg/cluster" 13 | ) 14 | 15 | func NewDockerDesktopCommand() *cobra.Command { 16 | var cmd = &cobra.Command{ 17 | Use: "docker-desktop", 18 | Short: "Debugging tool for the Docker Desktop client", 19 | Example: " ctlptl docker-desktop settings\n" + 20 | " ctlptl docker-desktop set KEY VALUE", 21 | } 22 | 23 | cmd.AddCommand(&cobra.Command{ 24 | Use: "settings", 25 | Short: "Print the docker-desktop settings", 26 | Run: withDockerDesktopClient("docker-desktop-settings", dockerDesktopSettings), 27 | Args: cobra.ExactArgs(0), 28 | }) 29 | 30 | cmd.AddCommand(&cobra.Command{ 31 | Use: "reset-cluster", 32 | Short: "Reset the docker-desktop Kubernetes cluster", 33 | Run: withDockerDesktopClient("docker-desktop-reset-cluster", dockerDesktopResetCluster), 34 | Args: cobra.ExactArgs(0), 35 | }) 36 | 37 | cmd.AddCommand(&cobra.Command{ 38 | Use: "open", 39 | Short: "Open docker-desktop", 40 | Run: withDockerDesktopClient("docker-desktop-open", dockerDesktopOpen), 41 | Args: cobra.ExactArgs(0), 42 | }) 43 | 44 | cmd.AddCommand(&cobra.Command{ 45 | Use: "quit", 46 | Short: "Shutdown docker-desktop", 47 | Run: withDockerDesktopClient("docker-desktop-quit", dockerDesktopQuit), 48 | Args: cobra.ExactArgs(0), 49 | }) 50 | 51 | cmd.AddCommand(&cobra.Command{ 52 | Use: "set KEY VALUE", 53 | Short: "Set the docker-desktop settings", 54 | Long: "Set the docker-desktop settings\n\n" + 55 | "The first argument is the full path to the setting.\n\n" + 56 | "The second argument is the desired value.\n\n" + 57 | "Most settings are scalars. vm.fileSharing is a list of paths separated by commas.", 58 | Example: " ctlptl docker-desktop set vm.resources.cpus 2\n" + 59 | " ctlptl docker-desktop set kubernetes.enabled false\n" + 60 | " ctlptl docker-desktop set vm.fileSharing /Users,/Volumes,/private,/tmp", 61 | Run: withDockerDesktopClient("docker-desktop-set", dockerDesktopSet), 62 | Args: cobra.ExactArgs(2), 63 | }) 64 | 65 | return cmd 66 | } 67 | 68 | func withDockerDesktopClient(name string, run func(client cluster.DockerDesktopClient, args []string) error) func(_ *cobra.Command, args []string) { 69 | return func(_ *cobra.Command, args []string) { 70 | a, err := newAnalytics() 71 | if err != nil { 72 | _, _ = fmt.Fprintf(os.Stderr, "analytics: %v\n", err) 73 | os.Exit(1) 74 | } 75 | a.Incr(fmt.Sprintf("cmd.%s", name), nil) 76 | defer a.Flush(time.Second) 77 | 78 | c, err := cluster.NewDockerDesktopClient() 79 | if err != nil { 80 | _, _ = fmt.Fprintf(os.Stderr, "ctlptl docker-desktop: %v\n", err) 81 | os.Exit(1) 82 | } 83 | 84 | err = run(c, args) 85 | if err != nil { 86 | _, _ = fmt.Fprintf(os.Stderr, "ctlptl docker-desktop: %v\n", err) 87 | os.Exit(1) 88 | } 89 | } 90 | } 91 | 92 | func dockerDesktopSettings(c cluster.DockerDesktopClient, args []string) error { 93 | settings, err := c.SettingsValues(context.Background()) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | encoder := yaml.NewEncoder(os.Stdout) 99 | return encoder.Encode(settings) 100 | } 101 | 102 | func dockerDesktopSet(c cluster.DockerDesktopClient, args []string) error { 103 | return c.SetSettingValue(context.Background(), args[0], args[1]) 104 | } 105 | 106 | func dockerDesktopResetCluster(c cluster.DockerDesktopClient, args []string) error { 107 | return c.ResetCluster(context.Background()) 108 | } 109 | 110 | func dockerDesktopOpen(c cluster.DockerDesktopClient, args []string) error { 111 | return c.Open(context.Background()) 112 | } 113 | 114 | func dockerDesktopQuit(c cluster.DockerDesktopClient, args []string) error { 115 | return c.Quit(context.Background()) 116 | } 117 | -------------------------------------------------------------------------------- /pkg/cluster/admin_k3d_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | 13 | "github.com/tilt-dev/ctlptl/internal/exec" 14 | "github.com/tilt-dev/ctlptl/pkg/api" 15 | "github.com/tilt-dev/ctlptl/pkg/api/k3dv1alpha4" 16 | "github.com/tilt-dev/ctlptl/pkg/api/k3dv1alpha5" 17 | ) 18 | 19 | func TestK3DStartFlagsV4(t *testing.T) { 20 | f := newK3DFixture() 21 | f.version = "v4.0.0" 22 | 23 | ctx := context.Background() 24 | v, err := f.a.version(ctx) 25 | require.NoError(t, err) 26 | assert.Equal(t, "4.0.0", v.String()) 27 | 28 | err = f.a.Create(ctx, &api.Cluster{ 29 | Name: "k3d-my-cluster", 30 | }, &api.Registry{Name: "my-reg"}) 31 | assert.NoError(t, err) 32 | assert.Equal(t, []string{ 33 | "k3d", "cluster", "create", "my-cluster", 34 | "--registry-use", "my-reg", 35 | }, f.runner.LastArgs) 36 | } 37 | 38 | func TestK3DStartFlagsV5(t *testing.T) { 39 | f := newK3DFixture() 40 | 41 | ctx := context.Background() 42 | v, err := f.a.version(ctx) 43 | require.NoError(t, err) 44 | assert.Equal(t, "5.6.0", v.String()) 45 | 46 | err = f.a.Create(ctx, &api.Cluster{ 47 | Name: "k3d-my-cluster", 48 | K3D: &api.K3DCluster{ 49 | V1Alpha4Simple: &k3dv1alpha4.SimpleConfig{ 50 | Network: "bar", 51 | }, 52 | }, 53 | }, &api.Registry{Name: "my-reg"}) 54 | require.NoError(t, err) 55 | assert.Equal(t, []string{ 56 | "k3d", "cluster", "create", "my-cluster", 57 | "--config", "-", 58 | }, f.runner.LastArgs) 59 | assert.Equal(t, f.runner.LastStdin, `kind: Simple 60 | apiVersion: k3d.io/v1alpha4 61 | metadata: 62 | name: my-cluster 63 | network: bar 64 | registries: 65 | use: 66 | - my-reg 67 | `) 68 | } 69 | 70 | func TestK3DV1alpha5File(t *testing.T) { 71 | f := newK3DFixture() 72 | 73 | ctx := context.Background() 74 | v, err := f.a.version(ctx) 75 | require.NoError(t, err) 76 | assert.Equal(t, "5.6.0", v.String()) 77 | 78 | err = f.a.Create(ctx, &api.Cluster{ 79 | Name: "k3d-my-cluster", 80 | K3D: &api.K3DCluster{ 81 | V1Alpha5Simple: &k3dv1alpha5.SimpleConfig{ 82 | Network: "bar", 83 | }, 84 | }, 85 | }, &api.Registry{Name: "my-reg"}) 86 | require.NoError(t, err) 87 | assert.Equal(t, []string{ 88 | "k3d", "cluster", "create", "my-cluster", 89 | "--config", "-", 90 | }, f.runner.LastArgs) 91 | assert.Equal(t, f.runner.LastStdin, `kind: Simple 92 | apiVersion: k3d.io/v1alpha5 93 | metadata: 94 | name: my-cluster 95 | network: bar 96 | registries: 97 | use: 98 | - my-reg 99 | `) 100 | } 101 | 102 | func TestK3DV1alpha4FileOnOldVersions(t *testing.T) { 103 | f := newK3DFixture() 104 | f.version = "v5.4.0" 105 | 106 | ctx := context.Background() 107 | err := f.a.Create(ctx, &api.Cluster{ 108 | Name: "k3d-my-cluster", 109 | }, &api.Registry{Name: "my-reg"}) 110 | require.NoError(t, err) 111 | assert.Equal(t, []string{ 112 | "k3d", "cluster", "create", "my-cluster", 113 | "--config", "-", 114 | }, f.runner.LastArgs) 115 | assert.Equal(t, f.runner.LastStdin, `kind: Simple 116 | apiVersion: k3d.io/v1alpha4 117 | metadata: 118 | name: my-cluster 119 | registries: 120 | use: 121 | - my-reg 122 | `) 123 | } 124 | 125 | type k3dFixture struct { 126 | runner *exec.FakeCmdRunner 127 | a *k3dAdmin 128 | version string 129 | } 130 | 131 | func newK3DFixture() *k3dFixture { 132 | iostreams := genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr} 133 | f := &k3dFixture{ 134 | version: "v5.6.0", 135 | } 136 | f.runner = exec.NewFakeCmdRunner(func(argv []string) string { 137 | if argv[1] == "version" { 138 | return fmt.Sprintf(`k3d version %s 139 | k3s version v1.24.4-k3s1 (default) 140 | `, f.version) 141 | } 142 | return "" 143 | }) 144 | f.a = newK3DAdmin(iostreams, f.runner) 145 | return f 146 | } 147 | -------------------------------------------------------------------------------- /pkg/cluster/admin_kind_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "k8s.io/cli-runtime/pkg/genericclioptions" 10 | 11 | "github.com/tilt-dev/ctlptl/internal/exec" 12 | "github.com/tilt-dev/ctlptl/pkg/api" 13 | ) 14 | 15 | func TestNodeImage(t *testing.T) { 16 | runner := exec.NewFakeCmdRunner(func(argv []string) string { 17 | return "" 18 | }) 19 | iostreams := genericclioptions.IOStreams{ 20 | In: os.Stdin, 21 | Out: os.Stdout, 22 | ErrOut: os.Stderr, 23 | } 24 | a := newKindAdmin(iostreams, runner, &fakeDockerClient{}) 25 | ctx := context.Background() 26 | 27 | img, err := a.getNodeImage(ctx, "v0.9.0", "v1.19") 28 | assert.NoError(t, err) 29 | assert.Equal(t, "kindest/node:v1.19.1@sha256:98cf5288864662e37115e362b23e4369c8c4a408f99cbc06e58ac30ddc721600", img) 30 | 31 | img, err = a.getNodeImage(ctx, "v0.9.0", "v1.19.3") 32 | assert.NoError(t, err) 33 | assert.Equal(t, "kindest/node:v1.19.1@sha256:98cf5288864662e37115e362b23e4369c8c4a408f99cbc06e58ac30ddc721600", img) 34 | 35 | img, err = a.getNodeImage(ctx, "v0.10.0", "v1.20") 36 | assert.NoError(t, err) 37 | assert.Equal(t, "kindest/node:v1.20.2@sha256:8f7ea6e7642c0da54f04a7ee10431549c0257315b3a634f6ef2fecaaedb19bab", img) 38 | 39 | img, err = a.getNodeImage(ctx, "v0.11.1", "v1.23") 40 | assert.NoError(t, err) 41 | assert.Equal(t, "kindest/node:v1.23.0@sha256:49824ab1727c04e56a21a5d8372a402fcd32ea51ac96a2706a12af38934f81ac", img) 42 | 43 | img, err = a.getNodeImage(ctx, "v0.8.1", "v1.16.1") 44 | assert.NoError(t, err) 45 | assert.Equal(t, "kindest/node:v1.16.9@sha256:7175872357bc85847ec4b1aba46ed1d12fa054c83ac7a8a11f5c268957fd5765", img) 46 | } 47 | 48 | func TestPatchRegistryConfig(t *testing.T) { 49 | nodeExec := []string{} 50 | runner := exec.NewFakeCmdRunner(func(argv []string) string { 51 | if argv[0] == "kind" && argv[1] == "get" && argv[2] == "nodes" { 52 | return `kind-external-load-balancer 53 | kind-control-plane 54 | kind-control-plane2 55 | ` 56 | } 57 | if argv[0] == "docker" && argv[1] == "exec" && argv[2] == "-i" { 58 | nodeExec = append(nodeExec, argv[3]) 59 | } 60 | return "" 61 | }) 62 | iostreams := genericclioptions.IOStreams{ 63 | In: os.Stdin, 64 | Out: os.Stdout, 65 | ErrOut: os.Stderr, 66 | } 67 | a := newKindAdmin(iostreams, runner, &fakeDockerClient{}) 68 | ctx := context.Background() 69 | 70 | err := a.applyContainerdPatchRegistryAPIV2( 71 | ctx, 72 | &api.Cluster{Name: "test-cluster"}, 73 | &api.Registry{Name: "test-registry"}) 74 | assert.NoError(t, err) 75 | 76 | // Assert that we only executed commands 77 | // in the control plane nodes, not the LB. 78 | assert.Equal(t, []string{ 79 | "kind-control-plane", 80 | "kind-control-plane", 81 | "kind-control-plane2", 82 | "kind-control-plane2", 83 | }, nodeExec) 84 | } 85 | 86 | func TestKindClusterConfigWithPullThroughRegistries(t *testing.T) { 87 | iostreams := genericclioptions.IOStreams{ 88 | In: os.Stdin, 89 | Out: os.Stdout, 90 | ErrOut: os.Stderr, 91 | } 92 | runner := exec.NewFakeCmdRunner(func(argv []string) string { 93 | return "" 94 | }) 95 | a := newKindAdmin(iostreams, runner, &fakeDockerClient{}) 96 | 97 | desired := &api.Cluster{ 98 | RegistryAuths: []api.RegistryAuth{ 99 | { 100 | Host: "example.com", 101 | Endpoint: "http://example.com:5000", 102 | Username: "user", 103 | Password: "pass", 104 | }, 105 | }, 106 | } 107 | 108 | kindConfig, err := a.kindClusterConfig(desired, nil, containerdRegistryV2) 109 | assert.NoError(t, err) 110 | 111 | expectedMirror := `[plugins."io.containerd.grpc.v1.cri".registry.mirrors."example.com"] 112 | endpoint = ["http://example.com:5000"] 113 | ` 114 | expectedAuth := `[plugins."io.containerd.grpc.v1.cri".registry.configs."example.com:5000".auth] 115 | username = "user" 116 | password = "pass" 117 | ` 118 | 119 | assert.Contains(t, kindConfig.ContainerdConfigPatches, expectedMirror) 120 | assert.Contains(t, kindConfig.ContainerdConfigPatches, expectedAuth) 121 | } 122 | -------------------------------------------------------------------------------- /pkg/cmd/get_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/tilt-dev/localregistry-go" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | 13 | "github.com/tilt-dev/ctlptl/pkg/api" 14 | "github.com/tilt-dev/ctlptl/pkg/cluster" 15 | "github.com/tilt-dev/ctlptl/pkg/registry" 16 | ) 17 | 18 | var createTime = time.Unix(1500000000, 0) 19 | var startTime = time.Unix(1600000000, 0) 20 | var clusterType = cluster.TypeMeta() 21 | var clusterList = &api.ClusterList{ 22 | TypeMeta: cluster.ListTypeMeta(), 23 | Items: []api.Cluster{ 24 | api.Cluster{ 25 | TypeMeta: clusterType, 26 | Name: "microk8s", 27 | Product: "microk8s", 28 | Status: api.ClusterStatus{ 29 | CreationTimestamp: metav1.Time{Time: createTime}, 30 | Current: true, 31 | }, 32 | }, 33 | api.Cluster{ 34 | TypeMeta: clusterType, 35 | Name: "kind-kind", 36 | Product: "KIND", 37 | Status: api.ClusterStatus{ 38 | CreationTimestamp: metav1.Time{Time: createTime}, 39 | LocalRegistryHosting: &localregistry.LocalRegistryHostingV1{ 40 | Host: "localhost:5000", 41 | }, 42 | }, 43 | }, 44 | }, 45 | } 46 | 47 | var registryType = registry.TypeMeta() 48 | var registryList = &api.RegistryList{ 49 | TypeMeta: registry.ListTypeMeta(), 50 | Items: []api.Registry{ 51 | api.Registry{ 52 | TypeMeta: registryType, 53 | Name: "ctlptl-registry", 54 | ListenAddress: "127.0.0.1", 55 | Port: 5001, 56 | Status: api.RegistryStatus{ 57 | CreationTimestamp: metav1.Time{Time: createTime}, 58 | IPAddress: "172.17.0.2", 59 | ListenAddress: "0.0.0.0", 60 | ContainerPort: 5000, 61 | HostPort: 5001, 62 | }, 63 | }, 64 | api.Registry{ 65 | TypeMeta: registryType, 66 | Name: "ctlptl-registry-loopback", 67 | ListenAddress: "127.0.0.1", 68 | Port: 5002, 69 | Status: api.RegistryStatus{ 70 | CreationTimestamp: metav1.Time{Time: createTime}, 71 | IPAddress: "172.17.0.3", 72 | ListenAddress: "127.0.0.1", 73 | ContainerPort: 5000, 74 | HostPort: 5002, 75 | }, 76 | }, 77 | }, 78 | } 79 | 80 | func TestDefaultPrint(t *testing.T) { 81 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 82 | o := NewGetOptions() 83 | o.IOStreams = streams 84 | o.StartTime = startTime 85 | 86 | err := o.Print(o.toTable(clusterList)) 87 | require.NoError(t, err) 88 | assert.Equal(t, out.String(), `CURRENT NAME PRODUCT AGE REGISTRY 89 | * microk8s microk8s 3y none 90 | kind-kind KIND 3y localhost:5000 91 | `) 92 | } 93 | 94 | func TestYAML(t *testing.T) { 95 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 96 | o := NewGetOptions() 97 | o.IOStreams = streams 98 | o.StartTime = startTime 99 | 100 | err := o.Command().Flags().Set("output", "yaml") 101 | require.NoError(t, err) 102 | 103 | err = o.Print(clusterList) 104 | require.NoError(t, err) 105 | assert.Equal(t, `apiVersion: ctlptl.dev/v1alpha1 106 | items: 107 | - apiVersion: ctlptl.dev/v1alpha1 108 | kind: Cluster 109 | name: microk8s 110 | product: microk8s 111 | status: 112 | creationTimestamp: "2017-07-14T02:40:00Z" 113 | current: true 114 | - apiVersion: ctlptl.dev/v1alpha1 115 | kind: Cluster 116 | name: kind-kind 117 | product: KIND 118 | status: 119 | creationTimestamp: "2017-07-14T02:40:00Z" 120 | localRegistryHosting: 121 | host: localhost:5000 122 | kind: ClusterList 123 | `, out.String()) 124 | } 125 | 126 | func TestRegistryPrint(t *testing.T) { 127 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 128 | o := NewGetOptions() 129 | o.IOStreams = streams 130 | o.StartTime = startTime 131 | 132 | err := o.Print(o.toTable(registryList)) 133 | require.NoError(t, err) 134 | assert.Equal(t, `NAME HOST ADDRESS CONTAINER ADDRESS AGE 135 | ctlptl-registry 0.0.0.0:5001 172.17.0.2:5000 3y 136 | ctlptl-registry-loopback 127.0.0.1:5002 172.17.0.3:5000 3y 137 | `, out.String()) 138 | } 139 | -------------------------------------------------------------------------------- /pkg/cmd/create_cluster.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/spf13/cobra" 11 | "k8s.io/apimachinery/pkg/api/errors" 12 | "k8s.io/cli-runtime/pkg/genericclioptions" 13 | 14 | "github.com/tilt-dev/clusterid" 15 | 16 | "github.com/tilt-dev/ctlptl/pkg/api" 17 | "github.com/tilt-dev/ctlptl/pkg/cluster" 18 | ) 19 | 20 | type CreateClusterOptions struct { 21 | *genericclioptions.PrintFlags 22 | genericclioptions.IOStreams 23 | 24 | Cluster *api.Cluster 25 | } 26 | 27 | func NewCreateClusterOptions() *CreateClusterOptions { 28 | o := &CreateClusterOptions{ 29 | PrintFlags: genericclioptions.NewPrintFlags("created"), 30 | IOStreams: genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr, In: os.Stdin}, 31 | Cluster: &api.Cluster{ 32 | TypeMeta: cluster.TypeMeta(), 33 | Minikube: &api.MinikubeCluster{}, 34 | }, 35 | } 36 | return o 37 | } 38 | 39 | func (o *CreateClusterOptions) Command() *cobra.Command { 40 | var cmd = &cobra.Command{ 41 | Use: "cluster [product]", 42 | Short: "Create a cluster with the given local Kubernetes product", 43 | Example: " ctlptl create cluster docker-desktop\n" + 44 | " ctlptl create cluster kind --registry=ctlptl-registry", 45 | Run: o.Run, 46 | Args: cobra.ExactArgs(1), 47 | } 48 | 49 | cmd.SetOut(o.Out) 50 | cmd.SetErr(o.ErrOut) 51 | o.AddFlags(cmd) 52 | cmd.Flags().StringVar(&o.Cluster.Registry, "registry", 53 | o.Cluster.Registry, "Connect the cluster to the named registry") 54 | cmd.Flags().StringVar(&o.Cluster.Name, "name", 55 | o.Cluster.Name, "Names the context. If not specified, uses the default cluster name for this Kubernetes product") 56 | cmd.Flags().IntVar(&o.Cluster.MinCPUs, "min-cpus", 57 | o.Cluster.MinCPUs, "Sets the minimum CPUs for the cluster") 58 | cmd.Flags().StringVar(&o.Cluster.KubernetesVersion, "kubernetes-version", 59 | o.Cluster.KubernetesVersion, "Sets the kubernetes version for the cluster, if possible") 60 | cmd.Flags().StringSliceVar(&o.Cluster.Minikube.StartFlags, "minikube-start-flags", 61 | o.Cluster.Minikube.StartFlags, "Minikube extra start flags (only applicable to a minikube cluster)") 62 | cmd.Flags().StringSliceVar(&o.Cluster.Minikube.ExtraConfigs, "minikube-extra-configs", 63 | o.Cluster.Minikube.ExtraConfigs, "Minikube extra configs (only applicable to a minikube cluster)") 64 | cmd.Flags().StringVar(&o.Cluster.Minikube.ContainerRuntime, "minikube-container-runtime", 65 | o.Cluster.Minikube.ContainerRuntime, "Minikube container runtime (only applicable to a minikube cluster)") 66 | 67 | return cmd 68 | } 69 | 70 | func (o *CreateClusterOptions) Run(cmd *cobra.Command, args []string) { 71 | controller, err := cluster.DefaultController(o.IOStreams) 72 | if err != nil { 73 | _, _ = fmt.Fprintf(o.ErrOut, "%v\n", err) 74 | os.Exit(1) 75 | } 76 | 77 | err = o.run(controller, args[0]) 78 | if err != nil { 79 | _, _ = fmt.Fprintf(o.ErrOut, "%v\n", err) 80 | os.Exit(1) 81 | } 82 | } 83 | 84 | type clusterCreator interface { 85 | Apply(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) 86 | Get(ctx context.Context, name string) (*api.Cluster, error) 87 | } 88 | 89 | func (o *CreateClusterOptions) run(controller clusterCreator, product string) error { 90 | a, err := newAnalytics() 91 | if err != nil { 92 | _, _ = fmt.Fprintf(o.ErrOut, "%v\n", err) 93 | os.Exit(1) 94 | } 95 | a.Incr("cmd.create.cluster", nil) 96 | defer a.Flush(time.Second) 97 | 98 | o.Cluster.Product = product 99 | 100 | // Zero out the minikube config if not used. 101 | if product != string(clusterid.ProductMinikube) || cmp.Equal(o.Cluster.Minikube, &api.MinikubeCluster{}) { 102 | o.Cluster.Minikube = nil 103 | } 104 | 105 | cluster.FillDefaults(o.Cluster) 106 | 107 | ctx := context.Background() 108 | _, err = controller.Get(ctx, o.Cluster.Name) 109 | if err == nil { 110 | return fmt.Errorf("Cannot create cluster: already exists") 111 | } else if err != nil && !errors.IsNotFound(err) { 112 | return fmt.Errorf("Cannot check cluster: %v", err) 113 | } 114 | 115 | applied, err := controller.Apply(ctx, o.Cluster) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | printer, err := o.ToPrinter() 121 | if err != nil { 122 | return err 123 | } 124 | 125 | return printer.PrintObj(applied, o.Out) 126 | } 127 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: ctlptl 3 | builds: 4 | - main: ./cmd/ctlptl/main.go 5 | goos: 6 | - linux 7 | - windows 8 | - darwin 9 | goarch: 10 | - amd64 11 | - arm64 12 | env: 13 | - CGO_ENABLED=0 14 | # https://goreleaser.com/deprecations/#builds-for-windowsarm64 15 | ignore: 16 | - goos: windows 17 | goarch: arm64 18 | archives: 19 | - name_template: >- 20 | {{ .ProjectName }}.{{ .Version }}. 21 | {{- if eq .Os "darwin"}}mac 22 | {{- else }}{{ .Os }}{{ end }}. 23 | {{- if eq .Arch "amd64" }}x86_64 24 | {{- else if eq .Arch "386" }}i386 25 | {{- else }}{{ .Arch }}{{ end }} 26 | format_overrides: 27 | - goos: windows 28 | format: zip 29 | checksum: 30 | name_template: 'checksums.txt' 31 | snapshot: 32 | version_template: "{{ .Tag }}-next" 33 | 34 | changelog: 35 | sort: asc 36 | use: github 37 | filters: 38 | exclude: 39 | - '^docs?:' 40 | - '^tests?:' 41 | - '^cleanup:' 42 | - '^circleci:' 43 | - '^ci:' 44 | 45 | brews: 46 | - repository: 47 | owner: tilt-dev 48 | name: homebrew-tap 49 | commit_author: 50 | name: Tilt Dev 51 | email: hi@tilt.dev 52 | url_template: "https://github.com/tilt-dev/ctlptl/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 53 | homepage: "https://ctlptl.dev/" 54 | description: "Making local Kubernetes clusters easy to set up and tear down" 55 | install: | 56 | bin.install "ctlptl" 57 | 58 | # Install bash completion 59 | output = Utils.safe_popen_read("#{bin}/ctlptl", "completion", "bash") 60 | (bash_completion/"ctlptl").write output 61 | 62 | # Install zsh completion 63 | output = Utils.safe_popen_read("#{bin}/ctlptl", "completion", "zsh") 64 | (zsh_completion/"_ctlptl").write output 65 | 66 | # Install fish completion 67 | output = Utils.safe_popen_read("#{bin}/ctlptl", "completion", "fish") 68 | (fish_completion/"ctlptl.fish").write output 69 | test: | 70 | system "#{bin}/ctlptl version" 71 | scoops: 72 | - url_template: "https://github.com/tilt-dev/ctlptl/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 73 | repository: 74 | owner: tilt-dev 75 | name: scoop-bucket 76 | commit_author: 77 | name: Tilt Dev 78 | email: hi@tilt.dev 79 | commit_msg_template: "Scoop update for {{ .ProjectName }} version {{ .Tag }}" 80 | homepage: "https://ctlptl.dev/" 81 | description: "Making local Kubernetes clusters easy to set up and tear down" 82 | license: Apache-2.0 83 | dockers: 84 | - goos: linux 85 | goarch: amd64 86 | image_templates: 87 | - "tiltdev/ctlptl:{{ .Tag }}-amd64" 88 | - "docker/tilt-ctlptl:{{ .Tag }}-amd64" 89 | dockerfile: hack/Dockerfile 90 | use: buildx 91 | build_flag_templates: 92 | - "--platform=linux/amd64" 93 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 94 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 95 | - "--label=org.opencontainers.image.url=https://github.com/tilt-dev/{{ .ProjectName }}" 96 | - "--label=org.opencontainers.image.source=https://github.com/tilt-dev/{{ .ProjectName }}" 97 | - "--label=org.opencontainers.image.version={{ .Version }}" 98 | - "--label=org.opencontainers.image.created={{ .Timestamp }}" 99 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 100 | - "--label=org.opencontainers.image.licenses=Apache-2.0" 101 | - goos: linux 102 | goarch: arm64 103 | goarm: '' 104 | image_templates: 105 | - "tiltdev/ctlptl:{{ .Tag }}-arm64" 106 | - "docker/tilt-ctlptl:{{ .Tag }}-arm64" 107 | dockerfile: hack/Dockerfile 108 | use: buildx 109 | build_flag_templates: 110 | - "--platform=linux/arm64" 111 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 112 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 113 | - "--label=org.opencontainers.image.url=https://github.com/tilt-dev/{{ .ProjectName }}" 114 | - "--label=org.opencontainers.image.source=https://github.com/tilt-dev/{{ .ProjectName }}" 115 | - "--label=org.opencontainers.image.version={{ .Version }}" 116 | - "--label=org.opencontainers.image.created={{ .Timestamp }}" 117 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 118 | - "--label=org.opencontainers.image.licenses=Apache-2.0" 119 | docker_manifests: 120 | - name_template: tiltdev/{{ .ProjectName }}:{{ .Tag }} 121 | image_templates: 122 | - tiltdev/{{ .ProjectName }}:{{ .Tag }}-amd64 123 | - tiltdev/{{ .ProjectName }}:{{ .Tag }}-arm64 124 | - name_template: tiltdev/{{ .ProjectName }}:latest 125 | image_templates: 126 | - tiltdev/{{ .ProjectName }}:{{ .Tag }}-amd64 127 | - tiltdev/{{ .ProjectName }}:{{ .Tag }}-arm64 128 | - name_template: docker/tilt-{{ .ProjectName }}:{{ .Tag }} 129 | image_templates: 130 | - docker/tilt-{{ .ProjectName }}:{{ .Tag }}-amd64 131 | - docker/tilt-{{ .ProjectName }}:{{ .Tag }}-arm64 132 | - name_template: docker/tilt-{{ .ProjectName }}:latest 133 | image_templates: 134 | - docker/tilt-{{ .ProjectName }}:{{ .Tag }}-amd64 135 | - docker/tilt-{{ .ProjectName }}:{{ .Tag }}-arm64 136 | 137 | 138 | # Uncomment these lines if you want to experiment with other 139 | # parts of the release process without releasing new binaries. 140 | # release: 141 | # disable: true 142 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | slack: circleci/slack@3.3.0 4 | kubernetes: circleci/kubernetes@0.11.1 5 | jobs: 6 | build: 7 | docker: 8 | - image: cimg/go:1.24 9 | steps: 10 | - checkout 11 | - run: go get -v -t -d ./... 12 | - run: go test -v ./... 13 | - run: cd .. && go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 14 | - run: make golangci-lint 15 | - slack/notify-on-failure: 16 | only_for_branches: main 17 | e2e-remote-docker: 18 | docker: 19 | - image: "docker/tilt-ctlptl-ci@sha256:ec301d936d736e5151cc138de734ead59685c6b2e1cb4b2a81d077c294a0a073" 20 | steps: 21 | - checkout 22 | - setup_remote_docker 23 | - run: make install 24 | - run: test/kind/e2e.sh 25 | e2e: 26 | machine: 27 | image: ubuntu-2204:2023.04.2 28 | steps: 29 | - checkout 30 | - kubernetes/install-kubectl 31 | - run: | 32 | set -ex 33 | wget https://golang.org/dl/go1.24.0.linux-amd64.tar.gz 34 | sudo rm -fR /usr/local/go 35 | sudo tar -C /usr/local -xzf go1.24.0.linux-amd64.tar.gz 36 | - run: | 37 | set -ex 38 | export MINIKUBE_VERSION=v1.34.0 39 | curl -fLo ./minikube-linux-amd64 "https://github.com/kubernetes/minikube/releases/download/${MINIKUBE_VERSION}/minikube-linux-amd64" 40 | chmod +x ./minikube-linux-amd64 41 | sudo mv ./minikube-linux-amd64 /usr/local/bin/minikube 42 | - run: | 43 | set -ex 44 | export KIND_VERSION=v0.31.0 45 | curl -fLo ./kind-linux-amd64 "https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64" 46 | chmod +x ./kind-linux-amd64 47 | sudo mv ./kind-linux-amd64 /usr/local/bin/kind 48 | - run: | 49 | set -ex 50 | export TAG=v5.6.0 51 | curl -s https://raw.githubusercontent.com/rancher/k3d/main/install.sh | bash 52 | - run: | 53 | set -ex 54 | go get -v -t -d ./... 55 | test/e2e.sh 56 | - slack/notify-on-failure: 57 | only_for_branches: main 58 | release-dry-run: 59 | docker: 60 | - image: golang:1.24-bookworm 61 | steps: 62 | - checkout 63 | - setup_remote_docker 64 | # https://discuss.circleci.com/t/arm-version-of-remote-docker/41624 65 | - run: ssh remote-docker "sudo apt-get update; sudo apt-get install -y qemu-user-static binfmt-support" 66 | - run: git fetch --tags 67 | - run: go install github.com/goreleaser/goreleaser/v2@v2.4.4 68 | - run: | 69 | set -e 70 | pushd /tmp 71 | apt-get update && apt-get install -y \ 72 | ca-certificates \ 73 | curl \ 74 | gnupg \ 75 | lsb-release 76 | mkdir -p /etc/apt/keyrings 77 | curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 78 | echo \ 79 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ 80 | $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 81 | apt-get update 82 | apt-get install -y docker-ce-cli docker-buildx-plugin 83 | docker --version 84 | rm -rf /var/lib/apt/lists/* 85 | 86 | popd 87 | - run: goreleaser --verbose --clean --skip=publish --snapshot 88 | - slack/notify-on-failure: 89 | only_for_branches: main 90 | release: 91 | docker: 92 | - image: golang:1.24-bookworm 93 | steps: 94 | - checkout 95 | - setup_remote_docker 96 | # https://discuss.circleci.com/t/arm-version-of-remote-docker/41624 97 | - run: ssh remote-docker "sudo apt-get update; sudo apt-get install -y qemu-user-static binfmt-support" 98 | - run: git fetch --tags 99 | - run: go install github.com/goreleaser/goreleaser/v2@v2.4.4 100 | - run: | 101 | set -e 102 | pushd /tmp 103 | apt-get update && apt-get install -y \ 104 | ca-certificates \ 105 | curl \ 106 | gnupg \ 107 | lsb-release 108 | mkdir -p /etc/apt/keyrings 109 | curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 110 | echo \ 111 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ 112 | $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 113 | apt-get update 114 | apt-get install -y docker-ce-cli docker-buildx-plugin 115 | docker --version 116 | rm -rf /var/lib/apt/lists/* 117 | 118 | popd 119 | - run: ./hack/release.sh 120 | - slack/status: 121 | mentions: "nick" 122 | workflows: 123 | version: 2 124 | build: 125 | jobs: 126 | - build 127 | - e2e: 128 | requires: 129 | - build 130 | - e2e-remote-docker: 131 | requires: 132 | - build 133 | - release-dry-run: 134 | requires: 135 | - build 136 | release: 137 | jobs: 138 | - release: 139 | context: 140 | - Tilt Release CLI Context 141 | - Tilt Docker Login Context 142 | filters: 143 | branches: 144 | only: never-release-on-a-branch 145 | tags: 146 | only: /v[0-9]+.[0-9]+.[0-9]+/ 147 | -------------------------------------------------------------------------------- /pkg/cmd/delete_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/cli-runtime/pkg/genericclioptions" 13 | 14 | "github.com/tilt-dev/ctlptl/pkg/api" 15 | ) 16 | 17 | func TestDeleteByName(t *testing.T) { 18 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 19 | o := NewDeleteOptions() 20 | o.IOStreams = streams 21 | 22 | cd := &fakeClusterController{} 23 | o.clusterController = cd 24 | err := o.run([]string{"cluster", "kind-kind"}) 25 | require.NoError(t, err) 26 | assert.Equal(t, "cluster.ctlptl.dev/kind-kind deleted\n", out.String()) 27 | assert.Equal(t, "kind-kind", cd.lastDeleteName) 28 | } 29 | 30 | func TestDeleteByFile(t *testing.T) { 31 | streams, in, out, _ := genericclioptions.NewTestIOStreams() 32 | o := NewDeleteOptions() 33 | o.IOStreams = streams 34 | 35 | _, _ = in.Write([]byte(`apiVersion: ctlptl.dev/v1alpha1 36 | kind: Cluster 37 | name: kind-kind 38 | `)) 39 | 40 | cd := &fakeClusterController{} 41 | o.clusterController = cd 42 | o.Filenames = []string{"-"} 43 | err := o.run([]string{}) 44 | require.NoError(t, err) 45 | assert.Equal(t, "cluster.ctlptl.dev/kind-kind deleted\n", out.String()) 46 | assert.Equal(t, "kind-kind", cd.lastDeleteName) 47 | } 48 | 49 | func TestDeleteDefault(t *testing.T) { 50 | streams, in, out, _ := genericclioptions.NewTestIOStreams() 51 | o := NewDeleteOptions() 52 | o.IOStreams = streams 53 | 54 | _, _ = in.Write([]byte(`apiVersion: ctlptl.dev/v1alpha1 55 | kind: Cluster 56 | product: kind 57 | `)) 58 | 59 | cd := &fakeClusterController{} 60 | o.clusterController = cd 61 | o.Filenames = []string{"-"} 62 | err := o.run([]string{}) 63 | require.NoError(t, err) 64 | assert.Equal(t, "cluster.ctlptl.dev/kind-kind deleted\n", out.String()) 65 | assert.Equal(t, "kind-kind", cd.lastDeleteName) 66 | } 67 | 68 | func TestDeleteNotFound(t *testing.T) { 69 | streams, _, _, _ := genericclioptions.NewTestIOStreams() 70 | o := NewDeleteOptions() 71 | o.IOStreams = streams 72 | 73 | cd := &fakeClusterController{nextError: errors.NewNotFound( 74 | schema.GroupResource{Group: "ctlptl.dev", Resource: "clusters"}, "garbage")} 75 | o.clusterController = cd 76 | err := o.run([]string{"cluster", "garbage"}) 77 | if assert.Error(t, err) { 78 | assert.Contains(t, err.Error(), `clusters.ctlptl.dev "garbage" not found`) 79 | } 80 | } 81 | 82 | func TestDeleteIgnoreNotFound(t *testing.T) { 83 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 84 | o := NewDeleteOptions() 85 | o.IOStreams = streams 86 | 87 | cd := &fakeClusterController{nextError: errors.NewNotFound( 88 | schema.GroupResource{Group: "ctlptl.dev", Resource: "clusters"}, "garbage")} 89 | o.clusterController = cd 90 | o.IgnoreNotFound = true 91 | err := o.run([]string{"cluster", "garbage"}) 92 | require.NoError(t, err) 93 | assert.Equal(t, "", out.String()) 94 | } 95 | 96 | func TestDeleteRegistryByFile(t *testing.T) { 97 | streams, in, out, _ := genericclioptions.NewTestIOStreams() 98 | o := NewDeleteOptions() 99 | o.IOStreams = streams 100 | 101 | _, _ = in.Write([]byte(`apiVersion: ctlptl.dev/v1alpha1 102 | kind: Registry 103 | port: 5002 104 | `)) 105 | 106 | rd := &fakeDeleter{} 107 | o.registryDeleter = rd 108 | o.Filenames = []string{"-"} 109 | err := o.run([]string{}) 110 | require.NoError(t, err) 111 | assert.Equal(t, "registry.ctlptl.dev/ctlptl-registry deleted\n", out.String()) 112 | assert.Equal(t, "ctlptl-registry", rd.lastName) 113 | } 114 | 115 | func TestDeleteCascade(t *testing.T) { 116 | streams, _, out, _ := genericclioptions.NewTestIOStreams() 117 | o := NewDeleteOptions() 118 | o.IOStreams = streams 119 | 120 | rd := &fakeDeleter{} 121 | cd := &fakeClusterController{ 122 | clusters: map[string]*api.Cluster{ 123 | "kind-kind": &api.Cluster{ 124 | Name: "kind-kind", 125 | Registry: "my-registry", 126 | }, 127 | }, 128 | } 129 | o.clusterController = cd 130 | o.registryDeleter = rd 131 | o.Cascade = "true" 132 | err := o.run([]string{"cluster", "kind-kind"}) 133 | require.NoError(t, err) 134 | assert.Equal(t, 135 | "registry.ctlptl.dev/my-registry deleted\n"+ 136 | "cluster.ctlptl.dev/kind-kind deleted\n", 137 | out.String()) 138 | assert.Equal(t, "my-registry", rd.lastName) 139 | } 140 | 141 | func TestDeleteCascadeStdin(t *testing.T) { 142 | streams, in, out, _ := genericclioptions.NewTestIOStreams() 143 | o := NewDeleteOptions() 144 | o.IOStreams = streams 145 | 146 | rd := &fakeDeleter{} 147 | cd := &fakeClusterController{ 148 | clusters: map[string]*api.Cluster{ 149 | "kind-kind": &api.Cluster{ 150 | Name: "kind-kind", 151 | Registry: "my-registry", 152 | }, 153 | }, 154 | } 155 | o.clusterController = cd 156 | o.registryDeleter = rd 157 | o.Cascade = "true" 158 | o.Filenames = []string{"-"} 159 | _, _ = io.WriteString(in, ` 160 | apiVersion: ctlptl.dev/v1alpha1 161 | kind: Registry 162 | name: my-registry 163 | port: 10000 164 | --- 165 | apiVersion: ctlptl.dev/v1alpha1 166 | kind: Cluster 167 | product: kind 168 | registry: my-registry 169 | `) 170 | err := o.run(nil) 171 | require.NoError(t, err) 172 | assert.Equal(t, 173 | "registry.ctlptl.dev/my-registry deleted\n"+ 174 | "cluster.ctlptl.dev/kind-kind deleted\n", 175 | out.String()) 176 | assert.Equal(t, "my-registry", rd.lastName) 177 | } 178 | 179 | func TestDeleteCascadeInvalid(t *testing.T) { 180 | streams, _, _, _ := genericclioptions.NewTestIOStreams() 181 | o := NewDeleteOptions() 182 | o.IOStreams = streams 183 | 184 | o.Cascade = "xxx" 185 | err := o.run([]string{"cluster", "kind-kind"}) 186 | if assert.Error(t, err) { 187 | require.Contains(t, err.Error(), "Invalid cascade: xxx. Valid values: true, false.") 188 | } 189 | } 190 | 191 | type fakeDeleter struct { 192 | lastName string 193 | nextError error 194 | } 195 | 196 | func (cd *fakeDeleter) Delete(ctx context.Context, name string) error { 197 | if cd.nextError != nil { 198 | return cd.nextError 199 | } 200 | cd.lastName = name 201 | return nil 202 | } 203 | -------------------------------------------------------------------------------- /internal/dctr/run.go: -------------------------------------------------------------------------------- 1 | package dctr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/distribution/reference" 9 | "github.com/docker/cli/cli/command" 10 | cliflags "github.com/docker/cli/cli/flags" 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/image" 14 | "github.com/docker/docker/api/types/network" 15 | registrytypes "github.com/docker/docker/api/types/registry" 16 | "github.com/docker/docker/api/types/system" 17 | "github.com/docker/docker/client" 18 | "github.com/docker/docker/registry" 19 | specs "github.com/opencontainers/image-spec/specs-go/v1" 20 | "github.com/pkg/errors" 21 | "github.com/spf13/pflag" 22 | "k8s.io/cli-runtime/pkg/genericclioptions" 23 | ) 24 | 25 | // Docker Container client. 26 | type Client interface { 27 | DaemonHost() string 28 | ImagePull(ctx context.Context, image string, options image.PullOptions) (io.ReadCloser, error) 29 | 30 | ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) 31 | ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) 32 | ContainerRemove(ctx context.Context, id string, options container.RemoveOptions) error 33 | ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *specs.Platform, containerName string) (container.CreateResponse, error) 34 | ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error 35 | 36 | ServerVersion(ctx context.Context) (types.Version, error) 37 | Info(ctx context.Context) (system.Info, error) 38 | NetworkConnect(ctx context.Context, networkID, containerID string, config *network.EndpointSettings) error 39 | NetworkDisconnect(ctx context.Context, networkID, containerID string, force bool) error 40 | } 41 | 42 | type CLI interface { 43 | Client() Client 44 | AuthInfo(ctx context.Context, repoInfo *registry.RepositoryInfo, cmdName string) (string, registrytypes.RequestAuthConfig, error) 45 | } 46 | 47 | type realCLI struct { 48 | cli *command.DockerCli 49 | } 50 | 51 | func (c *realCLI) Client() Client { 52 | return c.cli.Client() 53 | } 54 | 55 | func (c *realCLI) AuthInfo(ctx context.Context, repoInfo *registry.RepositoryInfo, cmdName string) (string, registrytypes.RequestAuthConfig, error) { 56 | authConfig := command.ResolveAuthConfig(c.cli.ConfigFile(), repoInfo.Index) 57 | requestPrivilege := command.RegistryAuthenticationPrivilegedFunc(c.cli, repoInfo.Index, cmdName) 58 | 59 | auth, err := registrytypes.EncodeAuthConfig(authConfig) 60 | if err != nil { 61 | return "", nil, errors.Wrap(err, "authInfo#EncodeAuthToBase64") 62 | } 63 | return auth, requestPrivilege, nil 64 | } 65 | 66 | func NewCLI(streams genericclioptions.IOStreams) (CLI, error) { 67 | dockerCli, err := command.NewDockerCli( 68 | command.WithOutputStream(streams.Out), 69 | command.WithErrorStream(streams.ErrOut)) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to create new docker API: %v", err) 72 | } 73 | 74 | opts := cliflags.NewClientOptions() 75 | flagSet := pflag.NewFlagSet("docker", pflag.ContinueOnError) 76 | opts.InstallFlags(flagSet) 77 | opts.SetDefaultOptions(flagSet) 78 | err = dockerCli.Initialize(opts) 79 | if err != nil { 80 | return nil, fmt.Errorf("initializing docker client: %v", err) 81 | } 82 | 83 | // A hack to see if initialization failed. 84 | // https://github.com/docker/cli/issues/4489 85 | endpoint := dockerCli.DockerEndpoint() 86 | if endpoint.Host == "" { 87 | return nil, fmt.Errorf("initializing docker client: no valid endpoint") 88 | } 89 | return &realCLI{cli: dockerCli}, nil 90 | } 91 | 92 | func NewAPIClient(streams genericclioptions.IOStreams) (Client, error) { 93 | cli, err := NewCLI(streams) 94 | if err != nil { 95 | return nil, err 96 | } 97 | return cli.Client(), nil 98 | } 99 | 100 | // A simplified remove-container-if-necessary helper. 101 | func RemoveIfNecessary(ctx context.Context, c Client, name string) error { 102 | co, err := c.ContainerInspect(ctx, name) 103 | if err != nil { 104 | if client.IsErrNotFound(err) { 105 | return nil 106 | } 107 | return err 108 | } 109 | if co.ContainerJSONBase == nil { 110 | return nil 111 | } 112 | 113 | return c.ContainerRemove(ctx, co.ID, container.RemoveOptions{ 114 | Force: true, 115 | }) 116 | } 117 | 118 | // A simplified run-container-and-detach helper for background support containers (like socat and the registry). 119 | func Run(ctx context.Context, cli CLI, name string, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) error { 120 | c := cli.Client() 121 | 122 | ctr, err := c.ContainerInspect(ctx, name) 123 | if err == nil && (ctr.ContainerJSONBase != nil && ctr.State.Running) { 124 | // The service is already running! 125 | return nil 126 | } else if err == nil { 127 | // The service exists, but is not running 128 | err := c.ContainerRemove(ctx, name, container.RemoveOptions{Force: true}) 129 | if err != nil { 130 | return fmt.Errorf("creating %s: %v", name, err) 131 | } 132 | } else if !client.IsErrNotFound(err) { 133 | return fmt.Errorf("inspecting %s: %v", name, err) 134 | } 135 | 136 | resp, err := c.ContainerCreate(ctx, config, hostConfig, networkingConfig, nil, name) 137 | if err != nil { 138 | if !client.IsErrNotFound(err) { 139 | return fmt.Errorf("creating %s: %v", name, err) 140 | } 141 | 142 | err := pull(ctx, cli, config.Image) 143 | if err != nil { 144 | return fmt.Errorf("pulling image %s: %v", config.Image, err) 145 | } 146 | 147 | resp, err = c.ContainerCreate(ctx, config, hostConfig, networkingConfig, nil, name) 148 | if err != nil { 149 | return fmt.Errorf("creating %s: %v", name, err) 150 | } 151 | } 152 | 153 | id := resp.ID 154 | err = c.ContainerStart(ctx, id, container.StartOptions{}) 155 | if err != nil { 156 | return fmt.Errorf("starting %s: %v", name, err) 157 | } 158 | return nil 159 | } 160 | 161 | func pull(ctx context.Context, cli CLI, img string) error { 162 | c := cli.Client() 163 | 164 | ref, err := reference.ParseNormalizedNamed(img) 165 | if err != nil { 166 | return fmt.Errorf("could not parse image %q: %v", img, err) 167 | } 168 | 169 | repoInfo, err := registry.ParseRepositoryInfo(ref) 170 | if err != nil { 171 | return fmt.Errorf("could not parse registry for %q: %v", img, err) 172 | } 173 | 174 | encodedAuth, requestPrivilege, err := cli.AuthInfo(ctx, repoInfo, "pull") 175 | if err != nil { 176 | return fmt.Errorf("could not authenticate: %v", err) 177 | } 178 | 179 | resp, err := c.ImagePull(ctx, img, image.PullOptions{ 180 | RegistryAuth: encodedAuth, 181 | PrivilegeFunc: requestPrivilege, 182 | }) 183 | if err != nil { 184 | return fmt.Errorf("pulling image %s: %v", img, err) 185 | } 186 | defer func() { 187 | _ = resp.Close() 188 | }() 189 | 190 | _, _ = io.Copy(io.Discard, resp) 191 | return nil 192 | } 193 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tilt-dev/ctlptl 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/blang/semver/v4 v4.0.0 9 | github.com/distribution/reference v0.6.0 10 | github.com/docker/cli v28.1.1+incompatible 11 | github.com/docker/docker v28.1.1+incompatible 12 | github.com/docker/go-connections v0.5.0 13 | github.com/google/go-cmp v0.7.0 14 | github.com/mitchellh/go-homedir v1.1.0 15 | github.com/opencontainers/image-spec v1.1.1 16 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 17 | github.com/pkg/errors v0.9.1 18 | github.com/shirou/gopsutil/v3 v3.24.5 19 | github.com/spf13/cobra v1.9.1 20 | github.com/spf13/pflag v1.0.6 21 | github.com/stretchr/testify v1.10.0 22 | github.com/tilt-dev/clusterid v0.1.6 23 | github.com/tilt-dev/localregistry-go v0.0.0-20201021185044-ffc4c827f097 24 | github.com/tilt-dev/wmclient v0.0.0-20201109174454-1839d0355fbc 25 | golang.org/x/sync v0.14.0 26 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce 27 | gopkg.in/yaml.v3 v3.0.1 28 | k8s.io/api v0.33.1 29 | k8s.io/apimachinery v0.33.1 30 | k8s.io/cli-runtime v0.33.1 31 | k8s.io/client-go v0.33.1 32 | k8s.io/klog/v2 v2.130.1 33 | sigs.k8s.io/kind v0.31.0 34 | ) 35 | 36 | require ( 37 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 38 | github.com/Microsoft/go-winio v0.6.2 // indirect 39 | github.com/beorn7/perks v1.0.1 // indirect 40 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 41 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 42 | github.com/containerd/log v0.1.0 // indirect 43 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 44 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 45 | github.com/denisbrodbeck/machineid v1.0.1 // indirect 46 | github.com/docker/distribution v2.8.3+incompatible // indirect 47 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 48 | github.com/docker/go-metrics v0.0.1 // indirect 49 | github.com/docker/go-units v0.5.0 // indirect 50 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect 51 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 52 | github.com/felixge/httpsnoop v1.0.4 // indirect 53 | github.com/fvbommel/sortorder v1.1.0 // indirect 54 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 55 | github.com/go-errors/errors v1.5.1 // indirect 56 | github.com/go-logr/logr v1.4.2 // indirect 57 | github.com/go-logr/stdr v1.2.2 // indirect 58 | github.com/go-ole/go-ole v1.3.0 // indirect 59 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 60 | github.com/go-openapi/jsonreference v0.21.0 // indirect 61 | github.com/go-openapi/swag v0.23.1 // indirect 62 | github.com/gogo/protobuf v1.3.2 // indirect 63 | github.com/google/btree v1.1.3 // indirect 64 | github.com/google/gnostic-models v0.6.9 // indirect 65 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 66 | github.com/google/uuid v1.6.0 // indirect 67 | github.com/gorilla/mux v1.8.1 // indirect 68 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 69 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 70 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 71 | github.com/josharian/intern v1.0.0 // indirect 72 | github.com/json-iterator/go v1.1.12 // indirect 73 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 74 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect 75 | github.com/mailru/easyjson v0.9.0 // indirect 76 | github.com/mattn/go-runewidth v0.0.16 // indirect 77 | github.com/miekg/pkcs11 v1.1.1 // indirect 78 | github.com/moby/docker-image-spec v1.3.1 // indirect 79 | github.com/moby/sys/atomicwriter v0.1.0 // indirect 80 | github.com/moby/sys/sequential v0.6.0 // indirect 81 | github.com/moby/term v0.5.2 // indirect 82 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 83 | github.com/modern-go/reflect2 v1.0.2 // indirect 84 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 85 | github.com/morikuni/aec v1.0.0 // indirect 86 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 87 | github.com/opencontainers/go-digest v1.0.0 // indirect 88 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 89 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 90 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 91 | github.com/prometheus/client_golang v1.22.0 // indirect 92 | github.com/prometheus/client_model v0.6.2 // indirect 93 | github.com/prometheus/common v0.64.0 // indirect 94 | github.com/prometheus/procfs v0.16.1 // indirect 95 | github.com/rivo/uniseg v0.4.7 // indirect 96 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 97 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 98 | github.com/sirupsen/logrus v1.9.3 // indirect 99 | github.com/theupdateframework/notary v0.7.0 // indirect 100 | github.com/tklauser/go-sysconf v0.3.15 // indirect 101 | github.com/tklauser/numcpus v0.10.0 // indirect 102 | github.com/x448/float16 v0.8.4 // indirect 103 | github.com/xlab/treeprint v1.2.0 // indirect 104 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 105 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 106 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 107 | go.opentelemetry.io/otel v1.35.0 // indirect 108 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect 109 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 110 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 111 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 // indirect 112 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 113 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 114 | go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect 115 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 116 | go.opentelemetry.io/proto/otlp v1.6.0 // indirect 117 | golang.org/x/net v0.40.0 // indirect 118 | golang.org/x/oauth2 v0.30.0 // indirect 119 | golang.org/x/sys v0.33.0 // indirect 120 | golang.org/x/term v0.32.0 // indirect 121 | golang.org/x/text v0.25.0 // indirect 122 | golang.org/x/time v0.11.0 // indirect 123 | google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 // indirect 124 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect 125 | google.golang.org/grpc v1.72.1 // indirect 126 | google.golang.org/protobuf v1.36.6 // indirect 127 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 128 | gopkg.in/inf.v0 v0.9.1 // indirect 129 | gopkg.in/yaml.v2 v2.4.0 // indirect 130 | gotest.tools/v3 v3.0.3 // indirect 131 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 132 | k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 // indirect 133 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 134 | sigs.k8s.io/kustomize/api v0.19.0 // indirect 135 | sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect 136 | sigs.k8s.io/randfill v1.0.0 // indirect 137 | sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect 138 | sigs.k8s.io/yaml v1.4.0 // indirect 139 | ) 140 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ### Summary 4 | * Treat everyone with respect and kindness 5 | * Be thoughtful in how you communicate 6 | * Don’t be destructive or inflammatory 7 | * If you encounter an issue, please email [**conduct@tilt.dev**](mailto:conduct@tilt.dev) 8 | 9 | ## Goals of This Document 10 | Windmill Engineering is committed to providing a friendly, safe, and welcoming environment for all of our users, contributors, followers, and Fans, regardless of: gender identity or expression; sexual orientation; disability; neurodivergence; physical appearance; body size; ethnicity; nationality; race; age; religion; level of technical experience; education; socio-economic status; or similar personal characteristics. 11 | 12 | The first goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk effectively, productively, and respectfully. 13 | 14 | The second goal is to provide a mechanism for resolving conflicts in the community when they arise. 15 | 16 | The third goal of the Code of Conduct is to make our community welcoming to people from different backgrounds. Diversity is critical to the project; for Windmill to be successful, it needs contributors and users from all backgrounds. 17 | 18 | We believe that healthy debate and disagreement are essential to a healthy project and community. However, it is never okay to be disrespectful. We value diverse opinions, but we value respectful behavior more. 19 | 20 | ## Code of Conduct 21 | ### Our Pledge 22 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of gender identity or expression, sexual orientation, disability, neurodivergence, physical appearance, body size, ethnicity, nationality, race, age, religion, level of technical experience, education, socio-economic status, or similar personal characteristics. 23 | 24 | ### Our Standards 25 | Examples of behavior that contributes to creating a positive environment include: 26 | * Using welcoming and inclusive language 27 | * Being respectful of differing viewpoints and experiences 28 | * Gracefully accepting constructive criticism 29 | * Focusing on what is best for the community 30 | * Showing empathy towards other community members 31 | 32 | Examples of unacceptable behavior by participants include: 33 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 34 | * Trolling, insulting/derogatory comments, and personal or political attacks 35 | * Public or private harassment 36 | * Unwelcome comments regarding a person’s lifestyle choices and practices, including those related to food, health, parenting, drugs, and employment 37 | * Publishing others’ private information, such as a physical or electronic address, without explicit permission (also known as “doxing”) 38 | * Deliberate [misgendering](https://www.healthline.com/health/transgender/misgendering#why-it-happens). This includes [deadnaming](https://www.healthline.com/health/transgender/deadnaming) or persistently using a pronoun that does not correctly reflect a person’s gender. You must address people by the name they give you when not addressing them by their username or handle 39 | * Physical contact and simulated physical contact (e.g., textual descriptions like `*hug*` or `*backrub*`) without consent or after a request to stop 40 | * Threats of violence, both physical and psychological 41 | * Incitement of violence towards any individual, including encouraging a person to commit suicide or to engage in self-harm 42 | * Harassing photography or recording, including logging online activity for harassment purposes 43 | * Continued one-on-one communication after requests to cease 44 | * Other conduct which could reasonably be considered inappropriate in a professional setting 45 | 46 | Our open source community prioritizes marginalized people’s safety over privileged people’s comfort. We will not act on complaints regarding: 47 | * “Reverse” -isms, including “reverse racism,” “reverse sexism,” and “cisphobia” 48 | * Reasonable communication of boundaries, such as “leave me alone,” “go away,” or “I’m not discussing this with you” 49 | * Refusal to explain or debate social justice concepts 50 | * Communicating in a “tone” you don’t find congenial 51 | * Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions 52 | 53 | ### Our Responsibilities 54 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 55 | 56 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 57 | 58 | ### Scope 59 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include: using an official project e-mail address; posting via an official social media account; or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 60 | 61 | This Code of Conduct also applies outside the project spaces when the project maintainers have a reasonable belief that an individual’s behavior may have a negative impact on the project or its community. 62 | 63 | ### Conflict Resolution 64 | We do not believe that all conflict is bad; healthy debate and disagreement often yield positive results. However, it is never okay to be disrespectful or to engage in behavior that violates the project’s Code of Conduct. 65 | 66 | If you see someone violating the Code of Conduct, you are encouraged to address the behavior directly with those involved. Many issues can be resolved quickly and easily, and this gives people more control over the outcome of their dispute. If you are unable to resolve the matter for any reason, or if the behavior is threatening or harassing, report it. We are dedicated to providing an environment where participants feel welcome and safe. 67 | 68 | Reports should be directed to **conduct@tilt.dev**. 69 | 70 | We will investigate every complaint, but you may not receive a direct response. We will use our discretion in determining when and how to follow up on reported incidents, which may range from not taking action to permanent expulsion from the project and project-sponsored spaces. We will notify the accused of the report and provide them an opportunity to discuss it before any action is taken. The identity of the reporter will be omitted from the details of the report supplied to the accused. In potentially harmful situations, such as ongoing harassment or threats to anyone’s safety, we may take action without notice. 71 | 72 | ### Attribution 73 | This Code of Conduct is adapted from the [Contributor Covenant (v1.4)](https://www.contributor-covenant.org/version/1/4/code-of-conduct), with additional content adapted from TODO Group’s [Open Code of Conduct](https://github.com/todogroup/opencodeofconduct) (no longer maintained). 74 | -------------------------------------------------------------------------------- /pkg/cluster/admin_k3d.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/blang/semver/v4" 11 | "github.com/pkg/errors" 12 | "github.com/tilt-dev/localregistry-go" 13 | "gopkg.in/yaml.v3" 14 | "k8s.io/cli-runtime/pkg/genericclioptions" 15 | "k8s.io/klog/v2" 16 | 17 | cexec "github.com/tilt-dev/ctlptl/internal/exec" 18 | "github.com/tilt-dev/ctlptl/pkg/api" 19 | "github.com/tilt-dev/ctlptl/pkg/api/k3dv1alpha4" 20 | "github.com/tilt-dev/ctlptl/pkg/api/k3dv1alpha5" 21 | ) 22 | 23 | // Support for v1alpha4 file format starts in 5.3.0. 24 | var v5_3 = semver.MustParse("5.3.0") 25 | 26 | // Support for v1alpha5 file format starts in 5.5.0. 27 | var v5_5 = semver.MustParse("5.5.0") 28 | 29 | // k3dAdmin uses the k3d CLI to manipulate a k3d cluster, 30 | // once the underlying machine has been setup. 31 | type k3dAdmin struct { 32 | iostreams genericclioptions.IOStreams 33 | runner cexec.CmdRunner 34 | } 35 | 36 | func newK3DAdmin(iostreams genericclioptions.IOStreams, runner cexec.CmdRunner) *k3dAdmin { 37 | return &k3dAdmin{ 38 | iostreams: iostreams, 39 | runner: runner, 40 | } 41 | } 42 | 43 | func (a *k3dAdmin) EnsureInstalled(ctx context.Context) error { 44 | _, err := exec.LookPath("k3d") 45 | if err != nil { 46 | return fmt.Errorf("k3d not installed. Please install k3d with these instructions: https://k3d.io/#installation") 47 | } 48 | return nil 49 | } 50 | 51 | func (a *k3dAdmin) Create(ctx context.Context, desired *api.Cluster, registry *api.Registry) error { 52 | klog.V(3).Infof("Creating cluster with config:\n%+v\n---\n", desired) 53 | if registry != nil { 54 | klog.V(3).Infof("Initializing cluster with registry config:\n%+v\n---\n", registry) 55 | } 56 | if len(desired.RegistryAuths) > 0 { 57 | return fmt.Errorf("ctlptl currently does not support connecting pull-through registries to k3d") 58 | } 59 | 60 | k3dV, err := a.version(ctx) 61 | if err != nil { 62 | return errors.Wrap(err, "detecting k3d version") 63 | } 64 | 65 | if desired.K3D != nil { 66 | if desired.K3D.V1Alpha4Simple != nil && k3dV.LT(v5_3) { 67 | return fmt.Errorf("k3d v1alpha4 config file only supported on v5.3+") 68 | } 69 | if desired.K3D.V1Alpha4Simple != nil && k3dV.LT(v5_5) { 70 | return fmt.Errorf("k3d v1alpha5 config file only supported on v5.5+") 71 | } 72 | if desired.K3D.V1Alpha5Simple != nil && desired.K3D.V1Alpha4Simple != nil { 73 | return fmt.Errorf("k3d config invalid: only one format allowed, both specified") 74 | } 75 | } 76 | 77 | // We generate a cluster config on all versions 78 | // because it does some useful validation. 79 | k3dConfig, err := a.clusterConfig(desired, registry, k3dV) 80 | if err != nil { 81 | return errors.Wrap(err, "creating k3d cluster") 82 | } 83 | 84 | // Delete any orphaned cluster resources, ignoring any errors. 85 | // This can happen if the cluster exists but has been removed from the kubeconfig. 86 | _ = a.Delete(ctx, desired) 87 | 88 | if k3dV.LT(v5_3) { 89 | // 5.2 and below 90 | args := []string{"cluster", "create", k3dConfig.name()} 91 | if registry != nil { 92 | args = append(args, "--registry-use", registry.Name) 93 | } 94 | 95 | err := a.runner.RunIO(ctx, 96 | genericclioptions.IOStreams{Out: a.iostreams.Out, ErrOut: a.iostreams.ErrOut}, 97 | "k3d", args...) 98 | if err != nil { 99 | return errors.Wrap(err, "creating k3d cluster") 100 | } 101 | 102 | return nil 103 | } 104 | 105 | // 5.3 and above. 106 | buf := bytes.NewBuffer(nil) 107 | encoder := yaml.NewEncoder(buf) 108 | err = encoder.Encode(k3dConfig.forEncoding()) 109 | if err != nil { 110 | return errors.Wrap(err, "creating k3d cluster") 111 | } 112 | 113 | args := []string{"cluster", "create", k3dConfig.name(), "--config", "-"} 114 | err = a.runner.RunIO(ctx, 115 | genericclioptions.IOStreams{In: buf, Out: a.iostreams.Out, ErrOut: a.iostreams.ErrOut}, 116 | "k3d", args...) 117 | if err != nil { 118 | return errors.Wrap(err, "creating k3d cluster") 119 | } 120 | 121 | return nil 122 | } 123 | 124 | // K3D manages the LocalRegistryHosting config itself :cheers: 125 | func (a *k3dAdmin) LocalRegistryHosting(ctx context.Context, desired *api.Cluster, registry *api.Registry) (*localregistry.LocalRegistryHostingV1, error) { 126 | return nil, nil 127 | } 128 | 129 | func (a *k3dAdmin) Delete(ctx context.Context, config *api.Cluster) error { 130 | clusterName := config.Name 131 | if !strings.HasPrefix(clusterName, "k3d-") { 132 | return fmt.Errorf("all k3d clusters must have a name with the prefix k3d-*") 133 | } 134 | 135 | k3dName := strings.TrimPrefix(clusterName, "k3d-") 136 | err := a.runner.RunIO(ctx, 137 | a.iostreams, 138 | "k3d", "cluster", "delete", k3dName) 139 | if err != nil { 140 | return errors.Wrap(err, "deleting k3d cluster") 141 | } 142 | return nil 143 | } 144 | 145 | func (a *k3dAdmin) version(ctx context.Context) (semver.Version, error) { 146 | out := bytes.NewBuffer(nil) 147 | err := a.runner.RunIO(ctx, 148 | genericclioptions.IOStreams{Out: out, ErrOut: a.iostreams.ErrOut}, 149 | "k3d", "version") 150 | if err != nil { 151 | return semver.Version{}, fmt.Errorf("k3d version: %v", err) 152 | } 153 | 154 | v := strings.TrimPrefix(strings.Split(out.String(), "\n")[0], "k3d version ") 155 | result, err := semver.ParseTolerant(v) 156 | if err != nil { 157 | return semver.Version{}, fmt.Errorf("k3d version: %v", err) 158 | } 159 | return result, nil 160 | } 161 | 162 | func (a *k3dAdmin) clusterConfig(desired *api.Cluster, registry *api.Registry, k3dv semver.Version) (*k3dClusterConfig, error) { 163 | var v4 *k3dv1alpha4.SimpleConfig 164 | var v5 *k3dv1alpha5.SimpleConfig 165 | if desired.K3D != nil && desired.K3D.V1Alpha5Simple != nil { 166 | v5 = desired.K3D.V1Alpha5Simple.DeepCopy() 167 | } else if desired.K3D != nil && desired.K3D.V1Alpha4Simple != nil { 168 | v4 = desired.K3D.V1Alpha4Simple.DeepCopy() 169 | } else if !k3dv.LT(v5_5) { 170 | v5 = &k3dv1alpha5.SimpleConfig{} 171 | } else { 172 | v4 = &k3dv1alpha4.SimpleConfig{} 173 | } 174 | 175 | if v5 != nil { 176 | v5.Kind = "Simple" 177 | v5.APIVersion = "k3d.io/v1alpha5" 178 | } else { 179 | v4.Kind = "Simple" 180 | v4.APIVersion = "k3d.io/v1alpha4" 181 | } 182 | 183 | clusterName := desired.Name 184 | if !strings.HasPrefix(clusterName, "k3d-") { 185 | return nil, fmt.Errorf("all k3d clusters must have a name with the prefix k3d-*") 186 | } 187 | 188 | if v5 != nil { 189 | v5.Name = strings.TrimPrefix(clusterName, "k3d-") 190 | if registry != nil { 191 | v5.Registries.Use = append(v5.Registries.Use, registry.Name) 192 | } 193 | } else { 194 | v4.Name = strings.TrimPrefix(clusterName, "k3d-") 195 | if registry != nil { 196 | v4.Registries.Use = append(v4.Registries.Use, registry.Name) 197 | } 198 | } 199 | return &k3dClusterConfig{ 200 | v1Alpha5: v5, 201 | v1Alpha4: v4, 202 | }, nil 203 | } 204 | 205 | // Helper struct for serializing different file formats. 206 | type k3dClusterConfig struct { 207 | v1Alpha5 *k3dv1alpha5.SimpleConfig 208 | v1Alpha4 *k3dv1alpha4.SimpleConfig 209 | } 210 | 211 | func (c *k3dClusterConfig) forEncoding() interface{} { 212 | if c.v1Alpha5 != nil { 213 | return c.v1Alpha5 214 | } 215 | if c.v1Alpha4 != nil { 216 | return c.v1Alpha4 217 | } 218 | return nil 219 | } 220 | 221 | func (c *k3dClusterConfig) name() string { 222 | if c.v1Alpha5 != nil { 223 | return c.v1Alpha5.Name 224 | } 225 | if c.v1Alpha4 != nil { 226 | return c.v1Alpha4.Name 227 | } 228 | return "" 229 | } 230 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ctlptl 2 | 3 | [![Build Status](https://circleci.com/gh/tilt-dev/ctlptl/tree/main.svg?style=shield)](https://circleci.com/gh/tilt-dev/ctlptl) 4 | [![GoDoc](https://godoc.org/github.com/tilt-dev/ctlptl?status.svg)](https://pkg.go.dev/github.com/tilt-dev/ctlptl) 5 | 6 | Want to mess around with Kubernetes, but don't want to spend an ocean on 7 | hardware? 8 | 9 | Maybe you need a `ctlptl`. 10 | 11 | ## What is ctlptl? 12 | 13 | `ctlptl` (pronounced "cattle patrol") is a CLI for declaratively setting up 14 | local Kubernetes clusters. 15 | 16 | Inspired by `kubectl` and 17 | [ClusterAPI's](https://github.com/kubernetes-sigs/cluster-api) `clusterctl`, you 18 | declare your local cluster with YAML and use `ctlptl` to set it up. 19 | 20 | ## How do I install it? 21 | 22 | Install your cluster of choice: [Docker for 23 | Desktop](https://www.docker.com/products/docker-desktop), 24 | [Kind](https://kind.sigs.k8s.io/), 25 | [k3d](https://k3d.io/) or 26 | [Minikube](https://minikube.sigs.k8s.io/). Then run: 27 | 28 | ### Homebrew (Mac/Linux) 29 | 30 | ``` 31 | brew install tilt-dev/tap/ctlptl 32 | ``` 33 | 34 | ### Scoop (Windows) 35 | 36 | ``` 37 | scoop bucket add tilt-dev https://github.com/tilt-dev/scoop-bucket 38 | scoop install ctlptl 39 | ``` 40 | 41 | ### Go install 42 | 43 | ``` 44 | go install github.com/tilt-dev/ctlptl/cmd/ctlptl@latest 45 | ``` 46 | 47 | ### Alternative Options 48 | 49 | If automatic installers aren't your cup of tea, check out the [installation 50 | appendix](INSTALL.md) for more options. 51 | 52 | ## How do I use it? 53 | 54 | `ctlptl` supports 4 major commands: 55 | 56 | - `ctlptl get` - see all running clusters 57 | - `ctlptl create cluster [product]` - create a cluster and make it the current `kubectl` context 58 | - `ctlptl apply -f cluster.yaml` - ensure a cluster exists, or create one 59 | - `ctlptl delete -f cluster.yaml` - delete a cluster and its state 60 | 61 | ### Examples 62 | 63 | #### Docker for Mac: Enable Kubernetes and set 4 CPU 64 | 65 | Create: 66 | 67 | ``` 68 | ctlptl docker-desktop open 69 | ctlptl create cluster docker-desktop --min-cpus=4 70 | ``` 71 | 72 | or ensure exists: 73 | 74 | ``` 75 | cat < 0 173 | hasNames := len(args) >= 2 174 | if !(hasFiles || hasNames) { 175 | return nil, fmt.Errorf("Expected resources, specified as files ('ctlptl delete -f') or names ('ctlptl delete cluster foo`)") 176 | } 177 | if hasFiles && hasNames { 178 | return nil, fmt.Errorf("Can only specify one of {files, resource names}") 179 | } 180 | 181 | if hasFiles { 182 | visitors, err := visitor.FromStrings(o.Filenames, o.In) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | return visitor.DecodeAll(visitors) 188 | } 189 | 190 | var resources []runtime.Object 191 | t := args[0] 192 | names := args[1:] 193 | switch t { 194 | case "cluster", "clusters": 195 | for _, name := range names { 196 | resources = append(resources, &api.Cluster{ 197 | TypeMeta: cluster.TypeMeta(), 198 | Name: name, 199 | }) 200 | } 201 | case "registry", "registries": 202 | for _, name := range names { 203 | resources = append(resources, &api.Registry{ 204 | TypeMeta: registry.TypeMeta(), 205 | Name: name, 206 | }) 207 | } 208 | default: 209 | return nil, fmt.Errorf("Unrecognized type: %s", t) 210 | } 211 | return resources, nil 212 | } 213 | 214 | func (o *DeleteOptions) getClusterController() (clusterController, error) { 215 | if o.clusterController == nil { 216 | controller, err := cluster.DefaultController(o.IOStreams) 217 | if err != nil { 218 | return nil, err 219 | } 220 | o.clusterController = controller 221 | } 222 | return o.clusterController, nil 223 | } 224 | 225 | // Interpret the current cascade mode, adding new resources to the list 226 | // before the resource that depends on them. 227 | func (o *DeleteOptions) cascadeResources(ctx context.Context, resources []runtime.Object) ([]runtime.Object, error) { 228 | if o.Cascade != "true" { 229 | return resources, nil 230 | } 231 | 232 | result := make([]runtime.Object, 0, len(resources)) 233 | registryNames := make(map[string]bool, 0) 234 | for _, r := range resources { 235 | switch r := r.(type) { 236 | case *api.Cluster: 237 | registryName := r.Registry 238 | 239 | // Check to see if we can find the cluster name in the registry status. 240 | if registryName == "" { 241 | controller, err := o.getClusterController() 242 | if err != nil { 243 | return nil, err 244 | } 245 | cluster, err := normalizedGet(ctx, controller, r.Name) 246 | if err != nil && !errors.IsNotFound(err) { 247 | return nil, err 248 | } 249 | if cluster != nil { 250 | registryName = cluster.Registry 251 | } 252 | } 253 | 254 | if registryName != "" && !registryNames[registryName] { 255 | registryNames[registryName] = true 256 | result = append(result, &api.Registry{ 257 | TypeMeta: registry.TypeMeta(), 258 | Name: registryName, 259 | }) 260 | } 261 | result = append(result, r) 262 | 263 | case *api.Registry: 264 | if registryNames[r.Name] { 265 | continue 266 | } 267 | registryNames[r.Name] = true 268 | result = append(result, r) 269 | } 270 | } 271 | 272 | return result, nil 273 | } 274 | 275 | func (o *DeleteOptions) validateCascade() error { 276 | if o.Cascade == "" || o.Cascade == "true" || o.Cascade == "false" { 277 | return nil 278 | } 279 | return fmt.Errorf("Invalid cascade: %s. Valid values: true, false.", o.Cascade) 280 | } 281 | -------------------------------------------------------------------------------- /pkg/api/k3dv1alpha4/types.go: -------------------------------------------------------------------------------- 1 | package k3dv1alpha4 2 | 3 | import "time" 4 | 5 | // NOTE(nicks): Forked from 6 | // https://github.com/k3d-io/k3d/blob/v5.4.6/pkg/config/v1alpha4/types.go 7 | // Modified to work with k8s api infra. 8 | 9 | // TypeMeta partially copies apimachinery/pkg/apis/meta/v1.TypeMeta 10 | // No need for a direct dependence; the fields are stable. 11 | type TypeMeta struct { 12 | Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` 13 | APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` 14 | } 15 | 16 | type ObjectMeta struct { 17 | Name string `mapstructure:"name,omitempty" json:"name,omitempty" yaml:"name,omitempty"` 18 | } 19 | 20 | type VolumeWithNodeFilters struct { 21 | Volume string `mapstructure:"volume" yaml:"volume,omitempty" json:"volume,omitempty"` 22 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty" json:"nodeFilters,omitempty"` 23 | } 24 | 25 | type PortWithNodeFilters struct { 26 | Port string `mapstructure:"port" yaml:"port,omitempty" json:"port,omitempty"` 27 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty" json:"nodeFilters,omitempty"` 28 | } 29 | 30 | type LabelWithNodeFilters struct { 31 | Label string `mapstructure:"label" yaml:"label,omitempty" json:"label,omitempty"` 32 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty" json:"nodeFilters,omitempty"` 33 | } 34 | 35 | type EnvVarWithNodeFilters struct { 36 | EnvVar string `mapstructure:"envVar" yaml:"envVar,omitempty" json:"envVar,omitempty"` 37 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty" json:"nodeFilters,omitempty"` 38 | } 39 | 40 | type K3sArgWithNodeFilters struct { 41 | Arg string `mapstructure:"arg" yaml:"arg,omitempty" json:"arg,omitempty"` 42 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty" json:"nodeFilters,omitempty"` 43 | } 44 | 45 | type SimpleConfigRegistryCreateConfig struct { 46 | Name string `mapstructure:"name" yaml:"name,omitempty" json:"name,omitempty"` 47 | Host string `mapstructure:"host" yaml:"host,omitempty" json:"host,omitempty"` 48 | HostPort string `mapstructure:"hostPort" yaml:"hostPort,omitempty" json:"hostPort,omitempty"` 49 | Image string `mapstructure:"image" yaml:"image,omitempty" json:"image,omitempty"` 50 | Volumes []string `mapstructure:"volumes" yaml:"volumes,omitempty" json:"volumes,omitempty"` 51 | } 52 | 53 | // SimpleConfigOptionsKubeconfig describes the set of options referring to the kubeconfig during cluster creation. 54 | type SimpleConfigOptionsKubeconfig struct { 55 | UpdateDefaultKubeconfig bool `mapstructure:"updateDefaultKubeconfig" yaml:"updateDefaultKubeconfig,omitempty" json:"updateDefaultKubeconfig,omitempty"` // default: true 56 | SwitchCurrentContext bool `mapstructure:"switchCurrentContext" yaml:"switchCurrentContext,omitempty" json:"switchCurrentContext,omitempty"` //nolint:lll // default: true 57 | } 58 | 59 | type SimpleConfigOptions struct { 60 | K3dOptions SimpleConfigOptionsK3d `mapstructure:"k3d" yaml:"k3d" json:"k3d"` 61 | K3sOptions SimpleConfigOptionsK3s `mapstructure:"k3s" yaml:"k3s" json:"k3s"` 62 | KubeconfigOptions SimpleConfigOptionsKubeconfig `mapstructure:"kubeconfig" yaml:"kubeconfig" json:"kubeconfig"` 63 | Runtime SimpleConfigOptionsRuntime `mapstructure:"runtime" yaml:"runtime" json:"runtime"` 64 | } 65 | 66 | type SimpleConfigOptionsRuntime struct { 67 | GPURequest string `mapstructure:"gpuRequest" yaml:"gpuRequest,omitempty" json:"gpuRequest,omitempty"` 68 | ServersMemory string `mapstructure:"serversMemory" yaml:"serversMemory,omitempty" json:"serversMemory,omitempty"` 69 | AgentsMemory string `mapstructure:"agentsMemory" yaml:"agentsMemory,omitempty" json:"agentsMemory,omitempty"` 70 | HostPidMode bool `mapstructure:"hostPidMode" yyaml:"hostPidMode,omitempty" json:"hostPidMode,omitempty"` 71 | Labels []LabelWithNodeFilters `mapstructure:"labels" yaml:"labels,omitempty" json:"labels,omitempty"` 72 | } 73 | 74 | type SimpleConfigOptionsK3d struct { 75 | Wait bool `mapstructure:"wait" yaml:"wait" json:"wait"` 76 | Timeout time.Duration `mapstructure:"timeout" yaml:"timeout,omitempty" json:"timeout,omitempty"` 77 | DisableLoadbalancer bool `mapstructure:"disableLoadbalancer" yaml:"disableLoadbalancer" json:"disableLoadbalancer"` 78 | DisableImageVolume bool `mapstructure:"disableImageVolume" yaml:"disableImageVolume" json:"disableImageVolume"` 79 | NoRollback bool `mapstructure:"disableRollback" yaml:"disableRollback" json:"disableRollback"` 80 | Loadbalancer SimpleConfigOptionsK3dLoadbalancer `mapstructure:"loadbalancer" yaml:"loadbalancer,omitempty" json:"loadbalancer,omitempty"` 81 | } 82 | 83 | type SimpleConfigOptionsK3dLoadbalancer struct { 84 | ConfigOverrides []string `mapstructure:"configOverrides" yaml:"configOverrides,omitempty" json:"configOverrides,omitempty"` 85 | } 86 | 87 | type SimpleConfigOptionsK3s struct { 88 | ExtraArgs []K3sArgWithNodeFilters `mapstructure:"extraArgs" yaml:"extraArgs,omitempty" json:"extraArgs,omitempty"` 89 | NodeLabels []LabelWithNodeFilters `mapstructure:"nodeLabels" yaml:"nodeLabels,omitempty" json:"nodeLabels,omitempty"` 90 | } 91 | 92 | type SimpleConfigRegistries struct { 93 | Use []string `mapstructure:"use" yaml:"use,omitempty" json:"use,omitempty"` 94 | Create *SimpleConfigRegistryCreateConfig `mapstructure:"create" yaml:"create,omitempty" json:"create,omitempty"` 95 | Config string `mapstructure:"config" yaml:"config,omitempty" json:"config,omitempty"` // registries.yaml (k3s config for containerd registry override) 96 | } 97 | 98 | type SimpleConfigHostAlias struct { 99 | IP string `mapstructure:"ip" yaml:"ip" json:"ip"` 100 | Hostnames []string `mapstructure:"hostnames" yaml:"hostnames" json:"hostnames"` 101 | } 102 | 103 | // SimpleConfig describes the toplevel k3d configuration file. 104 | type SimpleConfig struct { 105 | TypeMeta `yaml:",inline"` 106 | ObjectMeta `mapstructure:"metadata" yaml:"metadata,omitempty" json:"metadata,omitempty"` 107 | Servers int `mapstructure:"servers" yaml:"servers,omitempty" json:"servers,omitempty"` //nolint:lll // default 1 108 | Agents int `mapstructure:"agents" yaml:"agents,omitempty" json:"agents,omitempty"` //nolint:lll // default 0 109 | ExposeAPI SimpleExposureOpts `mapstructure:"kubeAPI" yaml:"kubeAPI,omitempty" json:"kubeAPI,omitempty"` 110 | Image string `mapstructure:"image" yaml:"image,omitempty" json:"image,omitempty"` 111 | Network string `mapstructure:"network" yaml:"network,omitempty" json:"network,omitempty"` 112 | Subnet string `mapstructure:"subnet" yaml:"subnet,omitempty" json:"subnet,omitempty"` 113 | ClusterToken string `mapstructure:"token" yaml:"clusterToken,omitempty" json:"clusterToken,omitempty"` // default: auto-generated 114 | Volumes []VolumeWithNodeFilters `mapstructure:"volumes" yaml:"volumes,omitempty" json:"volumes,omitempty"` 115 | Ports []PortWithNodeFilters `mapstructure:"ports" yaml:"ports,omitempty" json:"ports,omitempty"` 116 | Options SimpleConfigOptions `mapstructure:"options" yaml:"options,omitempty" json:"options,omitempty"` 117 | Env []EnvVarWithNodeFilters `mapstructure:"env" yaml:"env,omitempty" json:"env,omitempty"` 118 | Registries SimpleConfigRegistries `mapstructure:"registries" yaml:"registries,omitempty" json:"registries,omitempty"` 119 | HostAliases []SimpleConfigHostAlias `mapstructure:"hostAliases" yaml:"hostAliases,omitempty" json:"hostAliases,omitempty"` 120 | } 121 | 122 | // SimpleExposureOpts provides a simplified syntax compared to the original k3d.ExposureOpts 123 | type SimpleExposureOpts struct { 124 | Host string `mapstructure:"host" yaml:"host,omitempty" json:"host,omitempty"` 125 | HostIP string `mapstructure:"hostIP" yaml:"hostIP,omitempty" json:"hostIP,omitempty"` 126 | HostPort string `mapstructure:"hostPort" yaml:"hostPort,omitempty" json:"hostPort,omitempty"` 127 | } 128 | -------------------------------------------------------------------------------- /pkg/api/k3dv1alpha5/types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020-2022 The k3d Author(s) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | 23 | package k3dv1alpha5 24 | 25 | import ( 26 | "time" 27 | ) 28 | 29 | // TypeMeta partially copies apimachinery/pkg/apis/meta/v1.TypeMeta 30 | // No need for a direct dependence; the fields are stable. 31 | type TypeMeta struct { 32 | Kind string `yaml:"kind,omitempty"` 33 | APIVersion string `yaml:"apiVersion,omitempty"` 34 | } 35 | 36 | type ObjectMeta struct { 37 | Name string `mapstructure:"name,omitempty" yaml:"name,omitempty"` 38 | } 39 | 40 | type RegistryProxy struct { 41 | RemoteURL string `yaml:"remoteURL"` 42 | Username string `yaml:"username,omitempty"` 43 | Password string `yaml:"password,omitempty"` 44 | } 45 | 46 | type VolumeWithNodeFilters struct { 47 | Volume string `mapstructure:"volume" yaml:"volume,omitempty"` 48 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty"` 49 | } 50 | 51 | type PortWithNodeFilters struct { 52 | Port string `mapstructure:"port" yaml:"port,omitempty"` 53 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty"` 54 | } 55 | 56 | type LabelWithNodeFilters struct { 57 | Label string `mapstructure:"label" yaml:"label,omitempty"` 58 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty"` 59 | } 60 | 61 | type EnvVarWithNodeFilters struct { 62 | EnvVar string `mapstructure:"envVar" yaml:"envVar,omitempty"` 63 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty"` 64 | } 65 | 66 | type K3sArgWithNodeFilters struct { 67 | Arg string `mapstructure:"arg" yaml:"arg,omitempty"` 68 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty"` 69 | } 70 | 71 | type FileWithNodeFilters struct { 72 | Source string `mapstructure:"source" yaml:"source,omitempty"` 73 | Destination string `mapstructure:"destination" yaml:"destination,omitempty"` 74 | Description string `mapstructure:"description" yaml:"description,omitempty"` 75 | NodeFilters []string `mapstructure:"nodeFilters" yaml:"nodeFilters,omitempty"` 76 | } 77 | 78 | type SimpleConfigRegistryCreateConfig struct { 79 | Name string `mapstructure:"name" yaml:"name,omitempty"` 80 | Host string `mapstructure:"host" yaml:"host,omitempty"` 81 | HostPort string `mapstructure:"hostPort" yaml:"hostPort,omitempty"` 82 | Image string `mapstructure:"image" yaml:"image,omitempty"` 83 | Proxy RegistryProxy `mapstructure:"proxy" yaml:"proxy,omitempty"` 84 | Volumes []string `mapstructure:"volumes" yaml:"volumes,omitempty"` 85 | } 86 | 87 | // SimpleConfigOptionsKubeconfig describes the set of options referring to the kubeconfig during cluster creation. 88 | type SimpleConfigOptionsKubeconfig struct { 89 | UpdateDefaultKubeconfig bool `mapstructure:"updateDefaultKubeconfig" yaml:"updateDefaultKubeconfig,omitempty"` // default: true 90 | SwitchCurrentContext bool `mapstructure:"switchCurrentContext" yaml:"switchCurrentContext,omitempty"` //nolint:lll // default: true 91 | } 92 | 93 | type SimpleConfigOptions struct { 94 | K3dOptions SimpleConfigOptionsK3d `mapstructure:"k3d" yaml:"k3d"` 95 | K3sOptions SimpleConfigOptionsK3s `mapstructure:"k3s" yaml:"k3s"` 96 | KubeconfigOptions SimpleConfigOptionsKubeconfig `mapstructure:"kubeconfig" yaml:"kubeconfig"` 97 | Runtime SimpleConfigOptionsRuntime `mapstructure:"runtime" yaml:"runtime"` 98 | } 99 | 100 | type SimpleConfigOptionsRuntime struct { 101 | GPURequest string `mapstructure:"gpuRequest" yaml:"gpuRequest,omitempty"` 102 | ServersMemory string `mapstructure:"serversMemory" yaml:"serversMemory,omitempty"` 103 | AgentsMemory string `mapstructure:"agentsMemory" yaml:"agentsMemory,omitempty"` 104 | HostPidMode bool `mapstructure:"hostPidMode" yyaml:"hostPidMode,omitempty"` 105 | Labels []LabelWithNodeFilters `mapstructure:"labels" yaml:"labels,omitempty"` 106 | Ulimits []Ulimit `mapstructure:"ulimits" yaml:"ulimits,omitempty"` 107 | } 108 | 109 | type Ulimit struct { 110 | Name string `mapstructure:"name" yaml:"name"` 111 | Soft int64 `mapstructure:"soft" yaml:"soft"` 112 | Hard int64 `mapstructure:"hard" yaml:"hard"` 113 | } 114 | 115 | type SimpleConfigOptionsK3d struct { 116 | Wait bool `mapstructure:"wait" yaml:"wait"` 117 | Timeout time.Duration `mapstructure:"timeout" yaml:"timeout,omitempty"` 118 | DisableLoadbalancer bool `mapstructure:"disableLoadbalancer" yaml:"disableLoadbalancer"` 119 | DisableImageVolume bool `mapstructure:"disableImageVolume" yaml:"disableImageVolume"` 120 | NoRollback bool `mapstructure:"disableRollback" yaml:"disableRollback"` 121 | Loadbalancer SimpleConfigOptionsK3dLoadbalancer `mapstructure:"loadbalancer" yaml:"loadbalancer,omitempty"` 122 | } 123 | 124 | type SimpleConfigOptionsK3dLoadbalancer struct { 125 | ConfigOverrides []string `mapstructure:"configOverrides" yaml:"configOverrides,omitempty"` 126 | } 127 | 128 | type SimpleConfigOptionsK3s struct { 129 | ExtraArgs []K3sArgWithNodeFilters `mapstructure:"extraArgs" yaml:"extraArgs,omitempty"` 130 | NodeLabels []LabelWithNodeFilters `mapstructure:"nodeLabels" yaml:"nodeLabels,omitempty"` 131 | } 132 | 133 | type SimpleConfigRegistries struct { 134 | Use []string `mapstructure:"use" yaml:"use,omitempty"` 135 | Create *SimpleConfigRegistryCreateConfig `mapstructure:"create" yaml:"create,omitempty"` 136 | Config string `mapstructure:"config" yaml:"config,omitempty"` // registries.yaml (k3s config for containerd registry override) 137 | } 138 | 139 | type SimpleConfigHostAlias struct { 140 | IP string `mapstructure:"ip" yaml:"ip" json:"ip"` 141 | Hostnames []string `mapstructure:"hostnames" yaml:"hostnames" json:"hostnames"` 142 | } 143 | 144 | // SimpleConfig describes the toplevel k3d configuration file. 145 | type SimpleConfig struct { 146 | TypeMeta `mapstructure:",squash" yaml:",inline"` 147 | ObjectMeta `mapstructure:"metadata" yaml:"metadata,omitempty"` 148 | Servers int `mapstructure:"servers" yaml:"servers,omitempty"` //nolint:lll // default 1 149 | Agents int `mapstructure:"agents" yaml:"agents,omitempty"` //nolint:lll // default 0 150 | ExposeAPI SimpleExposureOpts `mapstructure:"kubeAPI" yaml:"kubeAPI,omitempty"` 151 | Image string `mapstructure:"image" yaml:"image,omitempty"` 152 | Network string `mapstructure:"network" yaml:"network,omitempty"` 153 | Subnet string `mapstructure:"subnet" yaml:"subnet,omitempty"` 154 | ClusterToken string `mapstructure:"token" yaml:"clusterToken,omitempty"` // default: auto-generated 155 | Volumes []VolumeWithNodeFilters `mapstructure:"volumes" yaml:"volumes,omitempty"` 156 | Ports []PortWithNodeFilters `mapstructure:"ports" yaml:"ports,omitempty"` 157 | Options SimpleConfigOptions `mapstructure:"options" yaml:"options,omitempty"` 158 | Env []EnvVarWithNodeFilters `mapstructure:"env" yaml:"env,omitempty"` 159 | Registries SimpleConfigRegistries `mapstructure:"registries" yaml:"registries,omitempty"` 160 | HostAliases []SimpleConfigHostAlias `mapstructure:"hostAliases" yaml:"hostAliases,omitempty"` 161 | Files []FileWithNodeFilters `mapstructure:"files" yaml:"files,omitempty"` 162 | } 163 | 164 | // SimpleExposureOpts provides a simplified syntax compared to the original k3d.ExposureOpts 165 | type SimpleExposureOpts struct { 166 | Host string `mapstructure:"host" yaml:"host,omitempty"` 167 | HostIP string `mapstructure:"hostIP" yaml:"hostIP,omitempty"` 168 | HostPort string `mapstructure:"hostPort" yaml:"hostPort,omitempty"` 169 | } 170 | -------------------------------------------------------------------------------- /pkg/cluster/machine.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "time" 12 | 13 | "github.com/mitchellh/go-homedir" 14 | "github.com/pkg/errors" 15 | "k8s.io/apimachinery/pkg/util/duration" 16 | "k8s.io/apimachinery/pkg/util/wait" 17 | "k8s.io/cli-runtime/pkg/genericclioptions" 18 | klog "k8s.io/klog/v2" 19 | 20 | "github.com/tilt-dev/clusterid" 21 | 22 | "github.com/tilt-dev/ctlptl/internal/dctr" 23 | cexec "github.com/tilt-dev/ctlptl/internal/exec" 24 | "github.com/tilt-dev/ctlptl/pkg/api" 25 | "github.com/tilt-dev/ctlptl/pkg/docker" 26 | ) 27 | 28 | type Machine interface { 29 | CPUs(ctx context.Context) (int, error) 30 | EnsureExists(ctx context.Context) error 31 | Restart(ctx context.Context, desired, existing *api.Cluster) error 32 | } 33 | 34 | type unknownMachine struct { 35 | product clusterid.Product 36 | } 37 | 38 | func (m unknownMachine) EnsureExists(ctx context.Context) error { 39 | return fmt.Errorf("cluster type %s not configurable", m.product) 40 | } 41 | 42 | func (m unknownMachine) CPUs(ctx context.Context) (int, error) { 43 | return 0, nil 44 | } 45 | 46 | func (m unknownMachine) Restart(ctx context.Context, desired, existing *api.Cluster) error { 47 | return fmt.Errorf("cluster type %s not configurable", desired.Product) 48 | } 49 | 50 | type sleeper func(dur time.Duration) 51 | 52 | type d4mClient interface { 53 | writeSettings(ctx context.Context, settings map[string]interface{}) error 54 | settings(ctx context.Context) (map[string]interface{}, error) 55 | ResetCluster(tx context.Context) error 56 | setK8sEnabled(settings map[string]interface{}, desired bool) (bool, error) 57 | ensureMinCPU(settings map[string]interface{}, desired int) (bool, error) 58 | Open(ctx context.Context) error 59 | } 60 | 61 | type dockerMachine struct { 62 | iostreams genericclioptions.IOStreams 63 | dockerClient dctr.Client 64 | sleep sleeper 65 | d4m d4mClient 66 | os string 67 | } 68 | 69 | func NewDockerMachine(ctx context.Context, client dctr.Client, iostreams genericclioptions.IOStreams) (*dockerMachine, error) { 70 | d4m, err := NewDockerDesktopClient() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return &dockerMachine{ 76 | dockerClient: client, 77 | iostreams: iostreams, 78 | sleep: time.Sleep, 79 | d4m: d4m, 80 | os: runtime.GOOS, 81 | }, nil 82 | } 83 | 84 | func (m *dockerMachine) CPUs(ctx context.Context) (int, error) { 85 | info, err := m.dockerClient.Info(ctx) 86 | if err != nil { 87 | return 0, err 88 | } 89 | return info.NCPU, nil 90 | } 91 | 92 | func (m *dockerMachine) EnsureExists(ctx context.Context) error { 93 | _, err := m.dockerClient.ServerVersion(ctx) 94 | if err == nil { 95 | return nil 96 | } 97 | 98 | host := m.dockerClient.DaemonHost() 99 | 100 | // If we are connecting to local desktop, we can try to start it. 101 | // Otherwise, we just error. 102 | if !docker.IsLocalDockerDesktop(host, m.os) { 103 | return fmt.Errorf("Not connected to Docker Engine. Host: %q. Error: %v", 104 | host, err) 105 | } 106 | 107 | klog.V(2).Infoln("No Docker Desktop running. Attempting to start Docker.") 108 | err = m.d4m.Open(ctx) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | dur := 60 * time.Second 114 | _, _ = fmt.Fprintf(m.iostreams.ErrOut, "Waiting %s for Docker Desktop to boot...\n", duration.ShortHumanDuration(dur)) 115 | err = wait.PollUntilContextTimeout(ctx, time.Second, dur, true, func(ctx context.Context) (bool, error) { 116 | _, err := m.dockerClient.ServerVersion(ctx) 117 | isSuccess := err == nil 118 | return isSuccess, nil 119 | }) 120 | if err != nil { 121 | return fmt.Errorf("timed out waiting for Docker to start") 122 | } 123 | klog.V(2).Infoln("Docker started successfully") 124 | return nil 125 | } 126 | 127 | func (m *dockerMachine) Restart(ctx context.Context, desired, existing *api.Cluster) error { 128 | canChangeCPUs := false 129 | isLocalDockerDesktop := false 130 | if docker.IsLocalDockerDesktop(m.dockerClient.DaemonHost(), m.os) { 131 | canChangeCPUs = true // DockerForMac and DockerForWindows can change the CPU on the VM 132 | isLocalDockerDesktop = true 133 | } else if clusterid.Product(desired.Product) == clusterid.ProductMinikube { 134 | // Minikube can change the CPU on the VM or on the container itself 135 | canChangeCPUs = true 136 | } 137 | 138 | if existing.Status.CPUs < desired.MinCPUs && !canChangeCPUs { 139 | return fmt.Errorf("Cannot automatically set minimum CPU to %d on this platform", desired.MinCPUs) 140 | } 141 | 142 | if isLocalDockerDesktop { 143 | settings, err := m.d4m.settings(ctx) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | k8sChanged := false 149 | if desired.Product == string(clusterid.ProductDockerDesktop) { 150 | k8sChanged, err = m.d4m.setK8sEnabled(settings, true) 151 | if err != nil { 152 | return err 153 | } 154 | } 155 | 156 | cpuChanged, err := m.d4m.ensureMinCPU(settings, desired.MinCPUs) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | if k8sChanged || cpuChanged { 162 | err := m.d4m.writeSettings(ctx, settings) 163 | if err != nil { 164 | return err 165 | } 166 | 167 | dur := 120 * time.Second 168 | _, _ = fmt.Fprintf(m.iostreams.ErrOut, 169 | "Applied new Docker Desktop settings. Waiting %s for Docker Desktop to restart...\n", 170 | duration.ShortHumanDuration(dur)) 171 | 172 | // Sleep for short time to ensure the write takes effect. 173 | m.sleep(2 * time.Second) 174 | 175 | err = wait.PollUntilContextTimeout(ctx, time.Second, dur, true, func(ctx context.Context) (bool, error) { 176 | _, err := m.dockerClient.ServerVersion(ctx) 177 | isSuccess := err == nil 178 | return isSuccess, nil 179 | }) 180 | if err != nil { 181 | return errors.Wrap(err, "Docker Desktop restart timeout") 182 | } 183 | } 184 | } 185 | 186 | return nil 187 | } 188 | 189 | // Currently, out Minikube admin only supports Minikube on Docker, 190 | // so we delegate to the dockerMachine driver. 191 | type minikubeMachine struct { 192 | iostreams genericclioptions.IOStreams 193 | runner cexec.CmdRunner 194 | dm *dockerMachine 195 | name string 196 | } 197 | 198 | func newMinikubeMachine(iostreams genericclioptions.IOStreams, runner cexec.CmdRunner, name string, dm *dockerMachine) *minikubeMachine { 199 | return &minikubeMachine{ 200 | iostreams: iostreams, 201 | runner: runner, 202 | name: name, 203 | dm: dm, 204 | } 205 | } 206 | 207 | type minikubeSettings struct { 208 | CPUs int 209 | } 210 | 211 | func (m *minikubeMachine) CPUs(ctx context.Context) (int, error) { 212 | homedir, err := homedir.Dir() 213 | if err != nil { 214 | return 0, err 215 | } 216 | configPath := filepath.Join(homedir, ".minikube", "profiles", m.name, "config.json") 217 | f, err := os.Open(configPath) 218 | if err != nil { 219 | return 0, err 220 | } 221 | defer func() { 222 | _ = f.Close() 223 | }() 224 | 225 | decoder := json.NewDecoder(f) 226 | settings := minikubeSettings{} 227 | err = decoder.Decode(&settings) 228 | if err != nil { 229 | return 0, err 230 | } 231 | return settings.CPUs, nil 232 | } 233 | 234 | func (m *minikubeMachine) EnsureExists(ctx context.Context) error { 235 | err := m.dm.EnsureExists(ctx) 236 | if err != nil { 237 | return err 238 | } 239 | 240 | m.startIfStopped(ctx) 241 | return nil 242 | } 243 | 244 | func (m *minikubeMachine) Restart(ctx context.Context, desired, existing *api.Cluster) error { 245 | return m.dm.Restart(ctx, desired, existing) 246 | } 247 | 248 | // Minikube is special because the "machine" can be stopped temporarily. 249 | // Check to see if there's a stopped machine, and start it. 250 | // Never return an error - if we can't proceed, we'll just restart from scratch. 251 | func (m *minikubeMachine) startIfStopped(ctx context.Context) { 252 | out := bytes.NewBuffer(nil) 253 | 254 | // Ignore errors. `minikube status` returns a non-zero exit code when 255 | // the container has been stopped. 256 | _ = m.runner.RunIO(ctx, genericclioptions.IOStreams{Out: out, ErrOut: m.iostreams.ErrOut}, 257 | "minikube", "status", "-p", m.name, "-o", "json") 258 | 259 | status := minikubeStatus{} 260 | decoder := json.NewDecoder(out) 261 | err := decoder.Decode(&status) 262 | if err != nil { 263 | return 264 | } 265 | 266 | // Handle 'minikube stop' 267 | if status.Host == "Stopped" { 268 | _, _ = fmt.Fprintf(m.iostreams.ErrOut, "Cluster %q exists but is stopped. Starting...\n", m.name) 269 | _ = m.runner.RunIO(ctx, m.iostreams, "minikube", "start", "-p", m.name) 270 | return 271 | } 272 | 273 | // Handle 'minikube pause' 274 | if status.APIServer == "Stopped" { 275 | _, _ = fmt.Fprintf(m.iostreams.ErrOut, "Cluster %q exists but is paused. Starting...\n", m.name) 276 | _ = m.runner.RunIO(ctx, m.iostreams, "minikube", "unpause", "-p", m.name) 277 | return 278 | } 279 | } 280 | 281 | type minikubeStatus struct { 282 | Host string 283 | APIServer string 284 | } 285 | --------------------------------------------------------------------------------