Browse Source

cmd/tailscale/cli,version/distro: update support for Alpine (#8701)

Similar to Arch support, use the latest version info from the official
`apk` repo and don't offer explicit track or version switching.
Add detection for Alpine Linux in version/distro along the way.

Updates #6995

Signed-off-by: Andrew Lytvynov <[email protected]>
Andrew Lytvynov 2 years ago
parent
commit
306deea03a
3 changed files with 140 additions and 3 deletions
  1. 65 3
      cmd/tailscale/cli/update.go
  2. 72 0
      cmd/tailscale/cli/update_test.go
  3. 3 0
      version/distro/distro.go

+ 65 - 3
cmd/tailscale/cli/update.go

@@ -45,9 +45,10 @@ var updateCmd = &ffcli.Command{
 		fs.BoolVar(&updateArgs.yes, "yes", false, "update without interactive prompts")
 		fs.BoolVar(&updateArgs.dryRun, "dry-run", false, "print what update would do without doing it, or prompts")
 		fs.BoolVar(&updateArgs.appStore, "app-store", false, "HIDDEN: check the App Store for updates, even if this is not an App Store install (for testing only)")
-		// These flags are not supported on Arch-based installs. Arch only
-		// offers one variant of tailscale and it's always the latest version.
-		if distro.Get() != distro.Arch {
+		// These flags are not supported on Arch or Alpine-based installs.
+		// Package repos on these distros only offer one variant of tailscale
+		// and it's always the latest version.
+		if distro.Get() != distro.Arch && distro.Get() != distro.Alpine {
 			fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`)
 			fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`)
 		}
@@ -145,6 +146,8 @@ func newUpdater() (*updater, error) {
 			up.update = up.updateDebLike
 		case distro.Arch:
 			up.update = up.updateArchLike
+		case distro.Alpine:
+			up.update = up.updateAlpineLike
 		}
 		// TODO(awly): add support for Alpine
 		switch {
@@ -158,6 +161,8 @@ func newUpdater() (*updater, error) {
 			up.update = up.updateFedoraLike("dnf")
 		case haveExecutable("yum"):
 			up.update = up.updateFedoraLike("yum")
+		case haveExecutable("apk"):
+			up.update = up.updateAlpineLike
 		}
 	case "darwin":
 		switch {
@@ -462,6 +467,63 @@ func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
 	return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
 }
 
+func (up *updater) updateAlpineLike() (err error) {
+	if err := requireRoot(); err != nil {
+		return err
+	}
+
+	defer func() {
+		if err != nil && !errors.Is(err, errUserAborted) {
+			err = fmt.Errorf(`%w; you can try updating using "apk upgrade tailscale"`, err)
+		}
+	}()
+
+	out, err := exec.Command("apk", "update").CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
+	}
+	out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
+	}
+	ver, err := parseAlpinePackageVersion(out)
+	if err != nil {
+		return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
+	}
+	if up.currentOrDryRun(ver) {
+		return nil
+	}
+	if err := up.confirm(ver); err != nil {
+		return err
+	}
+
+	cmd := exec.Command("apk", "upgrade", "tailscale")
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed tailscale update using apk: %w", err)
+	}
+	return nil
+}
+
+func parseAlpinePackageVersion(out []byte) (string, error) {
+	s := bufio.NewScanner(bytes.NewReader(out))
+	for s.Scan() {
+		// The line should look like this:
+		// tailscale-1.44.2-r0 description:
+		line := strings.TrimSpace(s.Text())
+		if !strings.HasPrefix(line, "tailscale-") {
+			continue
+		}
+		parts := strings.SplitN(line, "-", 3)
+		if len(parts) < 3 {
+			return "", fmt.Errorf("malformed info line: %q", line)
+		}
+		return parts[1], nil
+	}
+	return "", errors.New("tailscale version not found in output")
+}
+
 func (up *updater) updateMacSys() error {
 	// use sparkle? do we have permissions from this context? does sudo help?
 	// We can at least fail with a command they can run to update from the shell.

+ 72 - 0
cmd/tailscale/cli/update_test.go

@@ -368,3 +368,75 @@ skip_if_unavailable=False
 		})
 	}
 }
+
+func TestParseAlpinePackageVersion(t *testing.T) {
+	tests := []struct {
+		desc    string
+		out     string
+		want    string
+		wantErr bool
+	}{
+		{
+			desc: "valid version",
+			out: `
+tailscale-1.44.2-r0 description:
+The easiest, most secure way to use WireGuard and 2FA
+
+tailscale-1.44.2-r0 webpage:
+https://tailscale.com/
+
+tailscale-1.44.2-r0 installed size:
+32 MiB
+`,
+			want: "1.44.2",
+		},
+		{
+			desc: "wrong package output",
+			out: `
+busybox-1.36.1-r0 description:
+Size optimized toolbox of many common UNIX utilities
+
+busybox-1.36.1-r0 webpage:
+https://busybox.net/
+
+busybox-1.36.1-r0 installed size:
+924 KiB
+`,
+			wantErr: true,
+		},
+		{
+			desc: "missing version",
+			out: `
+tailscale description:
+The easiest, most secure way to use WireGuard and 2FA
+
+tailscale webpage:
+https://tailscale.com/
+
+tailscale installed size:
+32 MiB
+`,
+			wantErr: true,
+		},
+		{
+			desc:    "empty output",
+			out:     "",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			got, err := parseAlpinePackageVersion([]byte(tt.out))
+			if err == nil && tt.wantErr {
+				t.Fatalf("got nil error and version %q, want non-nil error", got)
+			}
+			if err != nil && !tt.wantErr {
+				t.Fatalf("got error: %q, want nil", err)
+			}
+			if got != tt.want {
+				t.Fatalf("got version: %q, want %q", got, tt.want)
+			}
+		})
+	}
+}

+ 3 - 0
version/distro/distro.go

@@ -30,6 +30,7 @@ const (
 	Gokrazy   = Distro("gokrazy")
 	WDMyCloud = Distro("wdmycloud")
 	Unraid    = Distro("unraid")
+	Alpine    = Distro("alpine")
 )
 
 var distro lazy.SyncValue[Distro]
@@ -93,6 +94,8 @@ func linuxDistro() Distro {
 		return WDMyCloud
 	case have("/etc/unraid-version"):
 		return Unraid
+	case have("/etc/alpine-release"):
+		return Alpine
 	}
 	return ""
 }