Repository: lima-vm/lima

Issue:
Kubernetes wants to stop distributing kubernetes-cni package
### Description

Currently we are using the upstream binaries that are installed in `/opt/cni`.

But Kubernetes and kubeadm wants to stop packaging "kubernetes-cni".

* https://github.com/kubernetes/release/issues/4156

Once they are no longer there, we need to switch to `/usr/local/libexec/cni`.

It is the same binaries (same source at least), just that they are installed separately.

```
Files /opt/cni/bin/LICENSE and /usr/local/libexec/cni/LICENSE are identical
Files /opt/cni/bin/README.md and /usr/local/libexec/cni/README.md are identical
Binary files /opt/cni/bin/bandwidth and /usr/local/libexec/cni/bandwidth differ
Binary files /opt/cni/bin/bridge and /usr/local/libexec/cni/bridge differ
Binary files /opt/cni/bin/dhcp and /usr/local/libexec/cni/dhcp differ
Binary files /opt/cni/bin/dummy and /usr/local/libexec/cni/dummy differ
Binary files /opt/cni/bin/firewall and /usr/local/libexec/cni/firewall differ
Only in /opt/cni/bin/: flannel
Binary files /opt/cni/bin/host-device and /usr/local/libexec/cni/host-device differ
Binary files /opt/cni/bin/host-local and /usr/local/libexec/cni/host-local differ
Binary files /opt/cni/bin/ipvlan and /usr/local/libexec/cni/ipvlan differ
Binary files /opt/cni/bin/loopback and /usr/local/libexec/cni/loopback differ
Binary files /opt/cni/bin/macvlan and /usr/local/libexec/cni/macvlan differ
Binary files /opt/cni/bin/portmap and /usr/local/libexec/cni/portmap differ
Binary files /opt/cni/bin/ptp and /usr/local/libexec/cni/ptp differ
Binary files /opt/cni/bin/sbr and /usr/local/libexec/cni/sbr differ
Binary files /opt/cni/bin/static and /usr/local/libexec/cni/static differ
Binary files /opt/cni/bin/tap and /usr/local/libexec/cni/tap differ
Binary files /opt/cni/bin/tuning and /usr/local/libexec/cni/tuning differ
Binary files /opt/cni/bin/vlan and /usr/local/libexec/cni/vlan differ
Binary files /opt/cni/bin/vrf and /usr/local/libexec/cni/vrf differ
```

Note: currently kubernetes uses 1.7.1, while nerdctl-full uses 1.8.0

https://github.com/containernetworking/plugins/releases

Installing the LICENSE and README in "bin" is an upstream packaging issue.

And the "flannel" binary is being installed from the kubernetes manifest:

```yaml
      initContainers:
      - args:
        - -f
        - /flannel
        - /opt/cni/bin/flannel
        command:
        - cp
        image: ghcr.io/flannel-io/flannel-cni-plugin:v1.6.2-flannel1
        name: install-cni-plugin
        volumeMounts:
        - mountPath: /opt/cni/bin
          name: cni-plugin
      - args:
        - -f
        - /etc/kube-flannel/cni-conf.json
        - /etc/cni/net.d/10-flannel.conflist
        command:
        - cp
        image: ghcr.io/flannel-io/flannel:v0.26.7
        name: install-cni
        volumeMounts:
        - mountPath: /etc/cni/net.d
          name: cni
        - mountPath: /etc/kube-flannel/
          name: flannel-cfg
```

https://github.com/flannel-io/cni-plugin/releases

Easiest might be a symlink (for compatibility), instead of changing all config?

`/opt/cni -> /usr/local/libexec/cni`

There is _already_ a conflict with the configuration directory, `/etc/cni/net.d`.

`rm -f /etc/cni/net.d/*.conf*`

The current workaround is to name the file so that it is first alphabetically.
Kubernetes doesn't support more than one config, while containerd does.
Trying to move the location of the config directory is an equal headache...

Discussion:
@afbjorklund: I created an issue, for the missing "vcs" information in the flannel cni plugin:

* https://github.com/flannel-io/cni-plugin/issues/135

It uses a different build script now, so it doesn't include the git information...

```
build	-ldflags="-extldflags \"-static\"  -w      -X main.Version=v1.6.2-flannel1     -X main.Commit=ed95d6d3 ...

build	vcs=git
build	vcs.revision=ed95d6d3cdd5e9d49907d1c851ddebb3b6db0b27
build	vcs.time=2025-01-31T08:50:04Z
build	vcs.modified=false
```@afbjorklund: This change might also impact `crictl` from the cri-tools package (also OBS).

It used to be required by `kubeadm`, but not anymore (since Kubernetes 1.32)

----

Unfortunately crictl is no longer provided with containerd (or nerdctl-full):

https://github.com/containerd/containerd/blob/main/docs/cri/crictl.md

So it needs to be installed separately, if you want it in addition to ctr and nerdctl.

https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/ ([cri-tools](https://github.com/kubernetes-sigs/cri-tools))@afbjorklund: The upstream documentation is not entirelly accurate:

`FATA[0000] failed to verify networking settings: failed to create default network: needs CNI plugin "bridge" to be installed in CNI_PATH ("/opt/cni/bin"), see https://github.com/containernetworking/plugins/releases: exec: "/opt/cni/bin/bridge": stat /opt/cni/bin/bridge: no such file or directory`

https://github.com/flannel-io/flannel

> Flannel uses `portmap` as CNI network plugin by default; when deploying Flannel ensure that the [CNI Network plugins](https://github.com/containernetworking/plugins) are installed in /opt/cni/bin

~~And we can't symlink either, since it runs inside a container...~~

The container installs `flannel` plugin to the host, and it runs there.

---

All these are needed, not just the `portmap`:

```
bridge
portmap
firewall
tuning
host-local
loopback
```
@afbjorklund: It is set here:

```
    [plugins.'io.containerd.cri.v1.runtime'.cni]
      bin_dir = ''
      bin_dirs = ['/opt/cni/bin']
      conf_dir = '/etc/cni/net.d'
      max_conf_num = 1
      setup_serially = false
      conf_template = ''
      ip_pref = ''
      use_internal_loopback = false

```

----

```toml
  [plugins.'io.containerd.cri.v1.runtime'.cni]
    bin_dirs = ['/opt/cni/bin', '/usr/local/libexec/cni']
```

/opt/cni/bin/
└── flannel
/usr/local/libexec/cni/
├── LICENSE
├── README.md
├── bandwidth
├── bridge
├── dhcp
├── dummy
├── firewall
├── host-device
├── host-local
├── ipvlan
├── loopback
├── macvlan
├── portmap
├── ptp
├── sbr
├── static
├── tap
├── tuning
├── vlan
└── vrf

@afbjorklund: If the OBS packages (pkgs.k8s.io) _also_ stop for some reason, they can be downloaded from dl.k8s.io instead...

The main difference is that they will not be compressed, and that the configuration files will not be bundled.

https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/

```shell
RELEASE="$(curl -sSL https://dl.k8s.io/release/stable.txt)"
ARCH="amd64"
cd /usr/bin
sudo curl -L --remote-name-all https://dl.k8s.io/release/${RELEASE}/bin/linux/${ARCH}/{kubeadm,kubelet,kubectl}
sudo chmod +x {kubeadm,kubelet,kubectl}

RELEASE_VERSION="v0.18.0"
curl -sSL "https://raw.githubusercontent.com/kubernetes/release/${RELEASE_VERSION}/cmd/krel/templates/latest/kubelet/kubelet.service" | sudo tee /usr/lib/systemd/system/kubelet.service
sudo mkdir -p /usr/lib/systemd/system/kubelet.service.d
curl -sSL "https://raw.githubusercontent.com/kubernetes/release/${RELEASE_VERSION}/cmd/krel/templates/latest/kubeadm/10-kubeadm.conf" | sudo tee /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf
```

* https://kubernetes.io/blog/2023/08/15/pkgs-k8s-io-introduction/

* https://kubernetes.io/blog/2023/08/31/legacy-package-repository-deprecation/@BenTheElder: > If the OBS packages (pkgs.k8s.io) also stop for some reason, they can be downloaded from dl.k8s.io instead...

We would stop hosting them at dl.k8s.io for any future releases if not for the packages.

I do not think Kubernetes should be re-publishing third-party binaries, and we're likely to stop at some point.@afbjorklund: I was referring to the deb and rpm repacking of the Kubernetes binaries:

* kubeadm
* kubectl 
* kubelet

They can _also_ be installed directly from binaries, as a workaround.

Unfortunately not tarballs.

Solution patch:
diff --git a/Makefile b/Makefile
index 673888e7..7c5f7d1e 100644
--- a/Makefile
+++ b/Makefile
@@ -552,7 +552,7 @@ check-generated:
 		(echo "Please run 'make generate' when making changes to proto files and check-in the generated file changes" && false)
 
 .PHONY: bats
-bats: native
+bats: native limactl-plugins
 	PATH=$$PWD/_output/bin:$$PATH ./hack/bats/lib/bats-core/bin/bats --timing ./hack/bats/tests
 
 .PHONY: lint
diff --git a/cmd/limactl/clone.go b/cmd/limactl/clone.go
index dc016e83..464e6adc 100644
--- a/cmd/limactl/clone.go
+++ b/cmd/limactl/clone.go
@@ -30,7 +30,7 @@ func newCloneCommand() *cobra.Command {
 Not to be confused with 'limactl copy' ('limactl cp').
 `,
 		Args:              WrapArgsError(cobra.ExactArgs(2)),
-		RunE:              cloneAction,
+		RunE:              cloneOrRenameAction,
 		ValidArgsFunction: cloneBashComplete,
 		GroupID:           advancedCommand,
 	}
@@ -38,7 +38,22 @@ Not to be confused with 'limactl copy' ('limactl cp').
 	return cloneCommand
 }
 
-func cloneAction(cmd *cobra.Command, args []string) error {
+func newRenameCommand() *cobra.Command {
+	renameCommand := &cobra.Command{
+		Use: "rename OLDINST NEWINST",
+		// No "mv" alias, to avoid confusion with a theoretical equivalent of `limactl cp` but s/cp/mv/.
+		Short:             "Rename an instance of Lima",
+		Args:              WrapArgsError(cobra.ExactArgs(2)),
+		RunE:              cloneOrRenameAction,
+		ValidArgsFunction: cloneBashComplete,
+		GroupID:           advancedCommand,
+	}
+	editflags.RegisterEdit(renameCommand, "[limactl edit] ")
+	return renameCommand
+}
+
+func cloneOrRenameAction(cmd *cobra.Command, args []string) error {
+	rename := cmd.Name() == "rename"
 	ctx := cmd.Context()
 	flags := cmd.Flags()
 	tty, err := flags.GetBool("tty")
@@ -55,7 +70,7 @@ func cloneAction(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	newInst, err := instance.Clone(ctx, oldInst, newInstName)
+	newInst, err := instance.CloneOrRename(ctx, oldInst, newInstName, rename)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go
index 874a4a10..a91332da 100644
--- a/cmd/limactl/main.go
+++ b/cmd/limactl/main.go
@@ -202,6 +202,7 @@ func newApp() *cobra.Command {
 		newStartAtLoginCommand(),
 		newNetworkCommand(),
 		newCloneCommand(),
+		newRenameCommand(),
 	)
 	addPluginCommands(rootCmd)
 
diff --git a/pkg/instance/clone.go b/pkg/instance/clone.go
index f4d1a013..4f886deb 100644
--- a/pkg/instance/clone.go
+++ b/pkg/instance/clone.go
@@ -22,7 +22,11 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/store"
 )
 
-func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string) (*limatype.Instance, error) {
+func CloneOrRename(ctx context.Context, oldInst *limatype.Instance, newInstName string, rename bool) (*limatype.Instance, error) {
+	verb := "clone"
+	if rename {
+		verb = "rename"
+	}
 	if newInstName == "" {
 		return nil, errors.New("got empty instName")
 	}
@@ -30,7 +34,7 @@ func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string)
 		return nil, fmt.Errorf("new instance name %q must be different from %q", newInstName, oldInst.Name)
 	}
 	if oldInst.Status == limatype.StatusRunning {
-		return nil, errors.New("cannot clone a running instance")
+		return nil, errors.New("cannot " + verb + " a running instance")
 	}
 
 	newInstDir, err := dirnames.InstanceDir(newInstName)
@@ -80,6 +84,9 @@ func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string)
 		if slices.Contains(filenames.NullifyOnClone, base) {
 			return os.WriteFile(dst, nil, 0o666)
 		}
+		if rename {
+			return os.Rename(path, dst)
+		}
 		// CopyFile attempts copy-on-write when supported by the filesystem
 		return continuityfs.CopyFile(dst, path)
 	}
@@ -87,6 +94,10 @@ func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string)
 	if err = filepath.WalkDir(oldInst.Dir, walkDirFn); err != nil {
 		return nil, err
 	}
-
+	if rename {
+		if err = os.RemoveAll(oldInst.Dir); err != nil {
+			return nil, err
+		}
+	}
 	return store.Inspect(ctx, newInstName)
 }
diff --git a/pkg/limatmpl/github.go b/pkg/limatmpl/github.go
index 1a3191dc..816a685f 100644
--- a/pkg/limatmpl/github.go
+++ b/pkg/limatmpl/github.go
@@ -16,6 +16,8 @@ import (
 	"strings"
 )
 
+const defaultFilename = ".lima.yaml"
+
 // transformGitHubURL transforms a github: URL to a raw.githubusercontent.com URL.
 // Input format: ORG/REPO[/PATH][@BRANCH]
 //
@@ -25,43 +27,37 @@ import (
 // If PATH is just a directory (trailing slash), it will be set to .lima.yaml
 // IF FILE is .lima.yaml and contents looks like a symlink, it will be replaced by the symlink target.
 func transformGitHubURL(ctx context.Context, input string) (string, error) {
-	// Check for explicit branch specification with @ at the end
-	var branch string
-	if idx := strings.LastIndex(input, "@"); idx != -1 {
-		branch = input[idx+1:]
-		input = input[:idx]
-	}
+	input, origBranch, _ := strings.Cut(input, "@")
 
 	parts := strings.Split(input, "/")
 	for len(parts) < 2 {
 		parts = append(parts, "")
 	}
-
 	org := parts[0]
 	if org == "" {
 		return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input)
 	}
-
 	// If REPO is omitted (github:ORG or github:ORG//PATH), default it to ORG
 	repo := cmp.Or(parts[1], org)
-	pathPart := strings.Join(parts[2:], "/")
+	filePath := strings.Join(parts[2:], "/")
 
-	if pathPart == "" {
-		pathPart = ".lima.yaml"
+	if filePath == "" {
+		filePath = defaultFilename
 	} else {
-		// If path ends with /, it's a directory, so append .lima
-		if strings.HasSuffix(pathPart, "/") {
-			pathPart += ".lima"
+		// If path ends with / then it's a directory, so append .lima
+		if strings.HasSuffix(filePath, "/") {
+			filePath += defaultFilename
 		}
 
 		// If the filename (excluding first char for hidden files) has no extension, add .yaml
-		filename := path.Base(pathPart)
+		filename := path.Base(filePath)
 		if !strings.Contains(filename[1:], ".") {
-			pathPart += ".yaml"
+			filePath += ".yaml"
 		}
 	}
 
 	// Query default branch if no branch was specified
+	branch := origBranch
 	if branch == "" {
 		var err error
 		branch, err = getGitHubDefaultBranch(ctx, org, repo)
@@ -71,13 +67,24 @@ func transformGitHubURL(ctx context.Context, input string) (string, error) {
 	}
 
 	// If filename is .lima.yaml, check if it's a symlink/redirect to another file
-	if path.Base(pathPart) == ".lima.yaml" {
-		if redirectPath, err := resolveGitHubSymlink(ctx, org, repo, branch, pathPart); err == nil {
-			pathPart = redirectPath
-		}
+	if path.Base(filePath) == defaultFilename {
+		return resolveGitHubSymlink(ctx, org, repo, branch, filePath, origBranch)
 	}
+	return githubUserContentURL(org, repo, branch, filePath), nil
+}
 
-	return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, pathPart), nil
+func githubUserContentURL(org, repo, branch, filePath string) string {
+	return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
+}
+
+func getGitHubUserContent(ctx context.Context, org, repo, branch, filePath string) (*http.Response, error) {
+	url := githubUserContentURL(org, repo, branch, filePath)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+	req.Header.Set("User-Agent", "lima")
+	return http.DefaultClient.Do(req)
 }
 
 // getGitHubDefaultBranch queries the GitHub API to get the default branch for a repository.
@@ -108,7 +115,6 @@ func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, erro
 	if err != nil {
 		return "", fmt.Errorf("failed to read GitHub API response: %w", err)
 	}
-
 	if resp.StatusCode != http.StatusOK {
 		return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
 	}
@@ -116,53 +122,89 @@ func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, erro
 	var repoData struct {
 		DefaultBranch string `json:"default_branch"`
 	}
-
 	if err := json.Unmarshal(body, &repoData); err != nil {
 		return "", fmt.Errorf("failed to parse GitHub API response: %w", err)
 	}
-
 	if repoData.DefaultBranch == "" {
 		return "", fmt.Errorf("repository %s/%s has no default branch", org, repo)
 	}
-
 	return repoData.DefaultBranch, nil
 }
 
 // resolveGitHubSymlink checks if a file at the given path is a symlink/redirect to another file.
-// If the file contains a single line without YAML content, it's treated as a path to the actual file.
-// Returns the redirect path if found, or the original path otherwise.
-func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath string) (string, error) {
-	url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
-
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
-	if err != nil {
-		return "", fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("User-Agent", "lima")
-
-	resp, err := http.DefaultClient.Do(req)
+// If the file contains a single line without newline, space, or colon then it's treated as a path to the actual file.
+// Returns a URL to the redirect path if found, or a URL for original path otherwise.
+func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath, origBranch string) (string, error) {
+	resp, err := getGitHubUserContent(ctx, org, repo, branch, filePath)
 	if err != nil {
 		return "", fmt.Errorf("failed to fetch file: %w", err)
 	}
 	defer resp.Body.Close()
 
+	// Special rule for branch/tag propagation for github:ORG// requests.
+	if resp.StatusCode == http.StatusNotFound && repo == org {
+		defaultBranch, err := getGitHubDefaultBranch(ctx, org, repo)
+		if err == nil {
+			return resolveGitHubRedirect(ctx, org, repo, defaultBranch, filePath, branch)
+		}
+	}
 	if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("file not found or inaccessible: status %d", resp.StatusCode)
+		return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
 	}
 
 	// Read first 1KB to check the file content
 	buf := make([]byte, 1024)
 	n, err := resp.Body.Read(buf)
 	if err != nil && !errors.Is(err, io.EOF) {
-		return "", fmt.Errorf("failed to read file content: %w", err)
+		return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
 	}
 	content := string(buf[:n])
 
+	// Symlink can also be a github: redirect if we are in a github:ORG// repo.
+	if repo == org && strings.HasPrefix(content, "github:") {
+		return validateGitHubRedirect(content, org, origBranch, resp.Request.URL.String())
+	}
+
 	// A symlink must be a single line (without trailing newline), no spaces, no colons
 	if !(content == "" || strings.ContainsAny(content, "\n :")) {
 		// symlink is relative to the directory of filePath
-		return path.Join(path.Dir(filePath), content), nil
+		filePath = path.Join(path.Dir(filePath), content)
+	}
+	return githubUserContentURL(org, repo, branch, filePath), nil
+}
+
+// resolveGitHubRedirect checks if a file at the given path is a github: URL to another file within the same repo.
+// Returns the URL, or an error if the file doesn't exist, or doesn't start with github:ORG.
+func resolveGitHubRedirect(ctx context.Context, org, repo, defaultBranch, filePath, origBranch string) (string, error) {
+	// Refetch the filepath from the defaultBranch
+	resp, err := getGitHubUserContent(ctx, org, repo, defaultBranch, filePath)
+	if err != nil {
+		return "", fmt.Errorf("failed to fetch file: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
+	}
+	return validateGitHubRedirect(string(body), org, origBranch, resp.Request.URL.String())
+}
+
+func validateGitHubRedirect(body, org, origBranch, url string) (string, error) {
+	redirect, _, _ := strings.Cut(body, "\n")
+	redirect = strings.TrimSpace(redirect)
+
+	if !strings.HasPrefix(redirect, "github:"+org+"/") {
+		return "", fmt.Errorf(`redirect %q is not a "github:%s" URL (from %q)`, redirect, org, url)
+	}
+	if strings.ContainsRune(redirect, '@') {
+		return "", fmt.Errorf("redirect %q must not include a branch/tag/sha (from %q)", redirect, url)
+	}
+	// If the origBranch is empty, then we need to look up the default branch in the redirect
+	if origBranch != "" {
+		redirect += "@" + origBranch
 	}
-	return filePath, nil
+	return redirect, nil
 }
diff --git a/pkg/limatmpl/locator.go b/pkg/limatmpl/locator.go
index d9a4e19f..3a94cf4f 100644
--- a/pkg/limatmpl/locator.go
+++ b/pkg/limatmpl/locator.go
@@ -296,7 +296,7 @@ func InstNameFromYAMLPath(yamlPath string) (string, error) {
 	return s, nil
 }
 
-func TransformCustomURL(ctx context.Context, locator string) (string, error) {
+func transformCustomURL(ctx context.Context, locator string) (string, error) {
 	u, err := url.Parse(locator)
 	if err != nil || len(u.Scheme) <= 1 {
 		return locator, nil
@@ -313,12 +313,7 @@ func TransformCustomURL(ctx context.Context, locator string) (string, error) {
 	}
 
 	if u.Scheme == "github" {
-		newLocator, err := transformGitHubURL(ctx, u.Opaque)
-		if err != nil {
-			return "", err
-		}
-		logrus.Warnf("GitHub locator %q replaced with %q is still EXPERIMENTAL", locator, newLocator)
-		return newLocator, nil
+		return transformGitHubURL(ctx, u.Opaque)
 	}
 
 	plugin, err := plugins.Find("url-" + u.Scheme)
@@ -350,9 +345,31 @@ func TransformCustomURL(ctx context.Context, locator string) (string, error) {
 		}
 		return "", fmt.Errorf("command %q failed: %w", cmd.String(), err)
 	}
-	newLocator := strings.TrimSpace(string(stdout))
-	if newLocator != locator {
-		logrus.Debugf("Custom locator %q replaced with %q", locator, newLocator)
+	return strings.TrimSpace(string(stdout)), nil
+}
+
+func TransformCustomURL(ctx context.Context, locator string) (string, error) {
+	seen := make(map[string]bool)
+	origLocator := locator
+	githubSchemeDetected := false
+
+	for !seen[locator] {
+		seen[locator] = true
+		if strings.HasPrefix(locator, "github:") {
+			githubSchemeDetected = true
+		}
+		newLocator, err := transformCustomURL(ctx, locator)
+		if err != nil {
+			return "", err
+		}
+		if newLocator == locator {
+			if githubSchemeDetected {
+				logrus.Warn("The github: scheme is still EXPERIMENTAL")
+			}
+			return newLocator, nil
+		}
+		logrus.Debugf("Locator %q replaced with %q", locator, newLocator)
+		locator = newLocator
 	}
-	return newLocator, nil
+	return "", fmt.Errorf("custom locator %q has a redirect loop", origLocator)
 }
diff --git a/templates/k8s.yaml b/templates/k8s.yaml
index ab89cd86..57243bdd 100644
--- a/templates/k8s.yaml
+++ b/templates/k8s.yaml
@@ -47,14 +47,6 @@ provision:
     echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v${VERSION}/deb/ /" | sudo tee /etc/apt/sources.list.d/kubernetes.list
     curl -fsSL https://pkgs.k8s.io/core:/stable:/v${VERSION}/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
     apt-get update
-    # cri-tools
-    apt-get install -y cri-tools
-    cat  <<EOF | sudo tee /etc/crictl.yaml
-    runtime-endpoint: unix:///run/containerd/containerd.sock
-    EOF
-    # cni-plugins
-    apt-get install -y kubernetes-cni
-    rm -f /etc/cni/net.d/*.conf*
     apt-get install -y kubelet kubeadm kubectl && apt-mark hold kubelet kubeadm kubectl
     systemctl enable --now kubelet
 # See <https://kubernetes.io/docs/setup/production-environment/container-runtimes/>
@@ -77,6 +69,8 @@ provision:
               runtime_type = "io.containerd.runc.v2"
               [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
                 SystemdCgroup = true
+      [plugins."io.containerd.cri.v1.runtime".cni]
+        bin_dirs = ["/usr/local/libexec/cni","/opt/cni/bin"]
     EOF
     systemctl restart containerd
 # See <https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/>
@@ -140,7 +134,7 @@ probes:
   script: |
     #!/bin/bash
     set -eux -o pipefail
-    if ! timeout 30s bash -c "images=\"$(kubeadm config images list)\"; until for image in \$images; do sudo crictl image -q \$image | grep -q sha256; done; do sleep 3; done"; then
+    if ! timeout 30s bash -c "images=\"$(kubeadm config images list)\"; until for image in \$images; do sudo ctr -n k8s.io image inspect \$image >/dev/null; done; do sleep 3; done"; then
       echo >&2 "k8s images are not pulled yet"
       exit 1
     fi
diff --git a/website/content/en/docs/examples/gha.md b/website/content/en/docs/examples/gha.md
index 46b94e93..4c0f9090 100644
--- a/website/content/en/docs/examples/gha.md
+++ b/website/content/en/docs/examples/gha.md
@@ -65,9 +65,6 @@ The `--plain` mode is useful when you want the VM instance to be as close as pos
       # --plain is set to disable file sharing, port forwarding, built-in containerd, etc.
       run: limactl start --plain --name=default --cpus=1 --memory=1 --network=lima:user-v2 template://fedora
 
-    - name: "Set up SSH"
-      uses: lima-vm/lima-actions/ssh@v1
-
     - name: "Initialize Fedora"
       # plain old rsync and ssh are used for the initialization of the guest,
       # so that people who are not familiar with Lima can understand the initialization steps.



Test patch:
diff --git a/hack/bats/tests/mcp.bats b/hack/bats/tests/mcp.bats
new file mode 100644
index 00000000..acc3aa70
--- /dev/null
+++ b/hack/bats/tests/mcp.bats
@@ -0,0 +1,331 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+load "../helpers/load"
+
+NAME=bats
+
+# TODO Move helper functions to shared location
+run_yq() {
+    run -0 --separate-stderr limactl yq "$@"
+}
+
+json_edit() {
+    limactl yq --input-format json --output-format json --indent 0 "$@"
+}
+
+# TODO The reusable Lima instance setup is copied from preserve-env.bats
+# TODO and should be factored out into helper functions.
+local_setup_file() {
+    if [[ -n "${LIMA_BATS_REUSE_INSTANCE:-}" ]]; then
+        run limactl list --format '{{.Status}}' "$NAME"
+        [[ $status == 0 ]] && [[ $output == "Running" ]] && return
+    fi
+    limactl unprotect "$NAME" || :
+    limactl delete --force "$NAME" || :
+    # Make sure that the host agent doesn't inherit file handles 3 or 4.
+    # Otherwise bats will not finish until the host agent exits.
+    limactl start --yes --name "$NAME" template://default 3>&- 4>&-
+}
+
+local_teardown_file() {
+    if [[ -z "${LIMA_BATS_REUSE_INSTANCE:-}" ]]; then
+        limactl delete --force "$NAME"
+    fi
+}
+
+local_setup() {
+    cd "$PATH_BATS_ROOT"
+    coproc MCP { limactl mcp serve "$NAME"; }
+
+    ID=0
+    mcp initialize '{"protocolVersion":"2025-06-18"}'
+
+    # Each mcp request should increment the ID
+    [[ $ID -eq 1 ]]
+
+    run_yq .serverInfo.name <<<"$output"
+    assert_output "lima"
+}
+
+local_teardown() {
+    kill "${MCP_PID:?}" 2>&1 >/dev/null || :
+}
+
+mcp() {
+    local method=$1
+    local params=${2:-}
+
+    local request
+    printf -v request '{"jsonrpc":"2.0","id":%d,"method":"%s"}' "$((++ID))" "$method"
+    if [[ -n $params ]]; then
+        request=$(json_edit ".params=${params}" <<<"$request")
+    fi
+
+    # send request to MCP server stdin
+    echo "$request" >&"${MCP[1]}"
+
+    # read response from MCP server stdout with 5s timeout
+    local json
+    read -t 5 -r json <&"${MCP[0]}"
+
+    # verify that the response matches the request; also validates the output is valid JSON
+    run_yq .id <<<"$json"
+    assert_output "$ID"
+
+    # there must be no error object in the response
+    run_yq .error <<<"$json"
+    assert_output "null"
+
+    # set $output to .result
+    run_yq .result <<<"$json"
+}
+
+tools_call() {
+    local name=$1
+    local args=${2:-}
+
+    local params
+    printf -v params '{"name":"%s"}' "$name"
+    if [[ -n $args ]]; then
+        params=$(json_edit ".arguments=${args}" <<<"$params")
+    fi
+    mcp tools/call "$params"
+}
+
+@test 'list tools' {
+    mcp tools/list
+    run_yq '.tools[].name' <<<"$output"
+    assert_line glob
+    assert_line list_directory
+    assert_line read_file
+    assert_line run_shell_command
+    assert_line search_file_content
+    assert_line write_file
+}
+
+@test 'verify that tools descriptions include input and output schema' {
+    mcp tools/list
+    run_yq '.tools[] | select(.name == "run_shell_command")' <<<"$output"
+    json=$output
+
+    run_yq '.inputSchema.required[]' <<<"$json"
+    assert_line command
+    assert_line directory
+    assert_output_lines_count 2
+
+    run_yq '.inputSchema.properties | keys[]' <<<"$json"
+    assert_line command
+    assert_line description
+    assert_line directory
+    assert_output_lines_count 3
+
+    run_yq '.outputSchema.required[]' <<<"$json"
+    assert_line stdout
+    assert_line stderr
+    assert_output_lines_count 2
+
+    run_yq '.outputSchema.properties | keys[]' <<<"$json"
+    assert_line error
+    assert_line exit_code
+    assert_line stdout
+    assert_line stderr
+    assert_output_lines_count 4
+}
+
+@test 'run shell command returns command output' {
+    run -0 limactl shell "$NAME" cat /etc/os-release
+    assert_output
+    expected=$output
+
+    tools_call run_shell_command '{"directory":"/etc","command":["cat","os-release"]}'
+    json=$output
+
+    run_yq '.structuredContent.exit_code' <<<"$json"
+    assert_output 0
+
+    run_yq '.structuredContent.stdout' <<<"$json"
+    assert_output "$expected"
+
+    run_yq '.structuredContent.stderr' <<<"$json"
+    refute_output
+
+    # The same data is also available as encoded JSON
+    run_yq '.content[0].type' <<<"$json"
+    assert_output "text"
+
+    run_yq '.content[0].text' <<<"$json"
+    text=$output
+
+    run_yq '.exit_code' <<<"$text"
+    assert_output 0
+
+    run_yq '.stdout' <<<"$text"
+    assert_output "$expected"
+
+    run_yq '.stderr' <<<"$text"
+    refute_output
+}
+
+@test 'run shell command returns stderr and exit code' {
+    tools_call run_shell_command '{"directory":"/","command":["bash","-c","echo NO>&2; exit 13"]}'
+    json=$output
+
+    run_yq '.structuredContent.exit_code' <<<"$json"
+    assert_output 13
+
+    run_yq '.structuredContent.error' <<<"$json"
+    assert_output "exit status 13"
+
+    run_yq '.structuredContent.stdout' <<<"$json"
+    refute_output
+
+    run_yq '.structuredContent.stderr' <<<"$json"
+    assert_output "NO"
+}
+
+@test 'run shell command fails if the directory does not exist' {
+    tools_call run_shell_command '{"directory":"/etcetera","command":["cat","os-release"]}'
+    json=$output
+
+    run_yq '.structuredContent.exit_code' <<<"$json"
+    assert_output 1
+
+    run_yq '.structuredContent.stderr' <<<"$json"
+    assert_output --partial "No such file or directory"
+}
+
+@test 'read_file reads a file' {
+    run -0 limactl shell "$NAME" cat /etc/os-release
+    assert_output
+    expected=$output
+
+    tools_call read_file '{"path":"/etc/os-release"}'
+    json=$output
+
+    run_yq '.content[0].text' <<<"$json"
+    run_yq '.content' <<<"$output"
+    assert_output "$expected"
+
+    run_yq '.structuredContent.content' <<<"$json"
+    assert_output "$expected"
+}
+
+@test 'read_file returns an error when path does not exist' {
+    tools_call read_file '{"path":"/etc/os-release-info"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output "file does not exist"
+}
+
+@test 'read_file returns an error when path is not absolute' {
+    tools_call read_file '{"path":"os-release"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output --partial "expected an absolute path"
+}
+
+@test 'write_file creates new file and overwrites existing file' {
+    limactl shell "$NAME" rm -f /tmp/mcp.test
+    tools_call write_file '{"path":"/tmp/mcp.test","content":"foo"}'
+
+    run_yq '.content[0].text' <<<"$output"
+    assert_output "{}"
+
+    run -0 limactl shell "$NAME" cat /tmp/mcp.test
+    assert_output "foo"
+
+    tools_call write_file '{"path":"/tmp/mcp.test","content":"bar"}'
+
+    run_yq '.content[0].text' <<<"$output"
+    assert_output "{}"
+
+    run -0 limactl shell "$NAME" cat /tmp/mcp.test
+    assert_output "bar"
+}
+
+@test 'write_file creates the directory if it does not yet exist' {
+    skip "see https://github.com/lima-vm/lima/issues/4174"
+
+    limactl shell "$NAME" rm -rf /tmp/tmp
+    tools_call write_file '{"path":"/tmp/tmp/tmp","content":"tmp"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "null"
+
+    run -0 limactl shell "$NAME" cat /tmp/tmp/tmp
+    assert_output "tmp"
+}
+
+@test 'write_file returns an error when the directory is not writable' {
+    limactl shell "$NAME" mkdir -p /tmp/tmp
+    limactl shell "$NAME" chmod 444 /tmp/tmp
+    tools_call write_file '{"path":"/tmp/tmp/tmp","content":"tmp"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output "permission denied"
+}
+
+@test 'write_file returns an error when path is not absolute' {
+    tools_call write_file '{"path":"tmp/mcp.test","content":"baz"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output --partial "expected an absolute path"
+}
+
+@test 'glob finds files by wildcard' {
+    tools_call glob '{"pattern":"*/*p.bats"}'
+
+    run_yq '.structuredContent.matches[]' <<<"$output"
+    assert_line --regexp '/tests/mcp.bats$'
+}
+
+@test 'glob returns an empty list when the pattern does not match' {
+    skip "see https://github.com/lima-vm/lima/issues/4173"
+
+    tools_call glob '{"pattern":"nothing.to.see"}'
+
+    run_yq '.structuredContent.matches[]' <<<"$output"
+    assert_output_lines_count 0
+}
+
+@test 'search_file_content finds text inside files' {
+    tools_call search_file_content '{"pattern":"needle in a haystack"}'
+
+    run_yq '.structuredContent.git_grep_output' <<<"$output"
+    assert_line --regexp '^tests/mcp.bats:[0-9]+: +tools_call'
+}
+
+@test 'search_file_content can find unicode characters above U+FFFF' {
+    # The light bulb emoji 💡 (U+1F4A1)
+    tools_call search_file_content '{"pattern":"💡"}'
+
+    run_yq '.structuredContent.git_grep_output' <<<"$output"
+    assert_line --regexp '^tests/mcp.bats:[0-9]+: +# The light bulb'
+    assert_line --regexp '^tests/mcp.bats:[0-9]+: +tools_call'
+}
+
+@test 'search_file_content returns an empty string if it cannot find the pattern' {
+    tools_call search_file_content "$(printf '{"pattern":"\U0001f4a1 not found"}')"
+
+    run_yq '.structuredContent.git_grep_output' <<<"$output"
+    refute_output
+}
+
diff --git a/hack/bats/tests/url-github.bats b/hack/bats/tests/url-github.bats
index b7b3163f..6853ac8a 100644
--- a/hack/bats/tests/url-github.bats
+++ b/hack/bats/tests/url-github.bats
@@ -6,19 +6,33 @@ load "../helpers/load"
 # The jandubois/jandubois GitHub repo has been especially constructed to test
 # various features of the github URL scheme:
 #
-# * repo defaults to org when not specified
-# * filename defaults to .lima.yaml when only a path is specified
-# * .yaml default extension
-# * .lima.yaml files may be treated as symlinks
-# * default branch lookup when not specified
+#   * repo defaults to org when not specified
+#   * filename defaults to .lima.yaml when only a path is specified
+#   * .yaml default extension
+#   * .lima.yaml files may be treated as symlinks
+#   * default branch lookup when not specified
+#   * github:ORG// repos can redirect to another github:ORG URL in the same ORG
 #
-# The repo files are:
+# The jandubois/jandubois repo files are:
 #
-# ├── .lima.yaml -> templates/demo.yaml
-# ├── docs
-# │   └── .lima.yaml -> ../templates/demo.yaml
-# └── templates
-#     └── demo.yaml
+#   ├── .lima.yaml         -> templates/demo.yaml
+#   ├── back
+#   │   └── .lima.yaml     -> github:jandubois//loop/
+#   ├── docs
+#   │   └── .lima.yaml     -> ../templates/demo.yaml
+#   ├── invalid
+#   │   ├── org
+#   │   │   └── .lima.yaml -> github:lima-vm
+#   │   └── tag
+#   │       └── .lima.yaml -> github:jandubois//@v0.0.0
+#   ├── loop
+#   │   └── .lima.yaml     -> github:jandubois//back/
+#   ├── redirect
+#   │   └── .lima.yaml     -> github:jandubois/lima/templates/default
+#   ├── templates
+#   │   └── demo.yaml      "base: template:default"
+#   └── yaml
+#       └── .lima.yaml     "{}"
 #
 # Both the `main` branch and the `v0.0.0` tag have this layout.
 
@@ -52,7 +66,7 @@ URLS=(
 )
 
 url() {
-	run_e "$1" limactl template url "$2"
+	run_e "$1" limactl --debug template url "$2"
 }
 
 test_jandubois_url() {
@@ -71,9 +85,14 @@ for url in "${URLS[@]}"; do
 	bats_test_function --description "$url" -- test_jandubois_url "$url"
 done
 
-@test '.lima.yaml is retained when it is not a symlink' {
-	url -0 'github:jandubois//test/'
-	assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.lima.yaml'
+@test '.lima.yaml is retained when it exits and is not a symlink' {
+	url -0 'github:jandubois//yaml/'
+	assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/yaml/.lima.yaml'
+}
+
+@test 'non-existing .lima.yaml returns an error' {
+	url -1 'github:jandubois//missing/'
+	assert_fatal 'file "https://raw.githubusercontent.com/jandubois/jandubois/main/missing/.lima.yaml" not found or inaccessible: status 404'
 }
 
 @test 'hidden files without an extension get a .yaml extension' {
@@ -88,15 +107,62 @@ done
 
 @test 'github: URLs are EXPERIMENTAL' {
 	url -0 'github:jandubois'
-	assert_stderr --regexp 'warning.+GitHub locator .* replaced with .* EXPERIMENTAL'
+	assert_warning "The github: scheme is still EXPERIMENTAL"
 }
 
-@test 'Empty github: url returns an error' {
+# Invalid URLs
+@test 'empty github: url returns an error' {
 	url -1 'github:'
 	assert_fatal 'github: URL must contain at least an ORG, got ""'
 }
 
-@test 'Missing org returns an error' {
+@test 'missing org returns an error' {
 	url -1 'github:/jandubois'
 	assert_fatal 'github: URL must contain at least an ORG, got ""'
 }
+
+# github: redirects in github:ORG// repos
+@test 'org redirects can point to different repo and may switch the branch name' {
+	url -0 'github:jandubois//redirect/'
+	# Note that the default branch in jandubois/jandubois is main, but in jandubois/lima it is master
+	assert_debug 'Locator "github:jandubois//redirect/" replaced with "github:jandubois/lima/templates/default"'
+	assert_debug 'Locator "github:jandubois/lima/templates/default" replaced with "https://raw.githubusercontent.com/jandubois/lima/master/templates/default.yaml"'
+	assert_output 'https://raw.githubusercontent.com/jandubois/lima/master/templates/default.yaml'
+}
+
+@test 'org redirects propagate an explicit branch/tag to the other repo' {
+	url -0 'github:jandubois//redirect/@v1.2.1'
+	assert_debug 'Locator "github:jandubois//redirect/@v1.2.1" replaced with "github:jandubois/lima/templates/default@v1.2.1"'
+	assert_debug 'Locator "github:jandubois/lima/templates/default@v1.2.1" replaced with "https://raw.githubusercontent.com/jandubois/lima/v1.2.1/templates/default.yaml"'
+	assert_output 'https://raw.githubusercontent.com/jandubois/lima/v1.2.1/templates/default.yaml'
+}
+
+@test 'org redirects cannot point to another org' {
+	url -1 'github:jandubois//invalid/org/'
+	assert_fatal 'redirect "github:lima-vm" is not a "github:jandubois" URL…'
+}
+
+@test 'org redirects with branch cannot point to another org' {
+	url -1 'github:jandubois//invalid/org/@main'
+	assert_fatal 'redirect "github:lima-vm" is not a "github:jandubois" URL…'
+}
+
+@test 'org redirects cannot include a branch or tag' {
+	url -1 'github:jandubois//invalid/tag/'
+	assert_fatal 'redirect "github:jandubois//@v0.0.0" must not include a branch/tag/sha…'
+}
+
+@test 'org redirects with tag cannot include a branch or tag' {
+	url -1 'github:jandubois//invalid/tag/@v0.0.0'
+	assert_fatal 'redirect "github:jandubois//@v0.0.0" must not include a branch/tag/sha…'
+}
+
+@test 'org redirects must not create circular redirects' {
+	url -1 'github:jandubois//loop/'
+	assert_fatal 'custom locator "github:jandubois//loop/" has a redirect loop'
+}
+
+@test 'org redirects with branch must not create circular redirects' {
+	url -1 'github:jandubois//back/@main'
+	assert_fatal 'custom locator "github:jandubois//back/@main" has a redirect loop'
+}