Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions dropkit/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -735,6 +737,26 @@ def wait_for_cloud_init(ssh_hostname: str, verbose: bool = False) -> tuple[bool,

# Tailscale helper functions

_MACOS_TAILSCALE_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"


def find_tailscale_cli() -> str | None:
"""
Find the Tailscale CLI binary.

Checks PATH first, then falls back to the macOS App Store location
where Tailscale is installed as a GUI app without a symlink in PATH.

Returns:
Path to the Tailscale CLI binary, or None if not found
"""
path = shutil.which("tailscale")
if path is not None:
return path
if sys.platform == "darwin" and Path(_MACOS_TAILSCALE_PATH).exists():
return _MACOS_TAILSCALE_PATH
return None


def check_local_tailscale() -> bool:
"""
Expand All @@ -743,18 +765,20 @@ def check_local_tailscale() -> bool:
Returns:
True if Tailscale is running and the user is connected to a tailnet
"""
tailscale_bin = find_tailscale_cli()
if tailscale_bin is None:
return False

try:
result = subprocess.run(
["tailscale", "status", "--json"],
[tailscale_bin, "status", "--json"],
capture_output=True,
timeout=5,
)
if result.returncode != 0:
return False

# Parse JSON to check if we're connected
status = json.loads(result.stdout.decode("utf-8", errors="ignore"))
# BackendState should be "Running" if connected
return status.get("BackendState") == "Running"
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
return False
Expand Down
66 changes: 57 additions & 9 deletions tests/test_tailscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dropkit.main import (
check_local_tailscale,
check_tailscale_installed,
find_tailscale_cli,
install_tailscale_on_droplet,
is_tailscale_ip,
lock_down_to_tailscale,
Expand Down Expand Up @@ -82,56 +83,103 @@ def test_invalid_ip_none_type(self):
assert is_tailscale_ip(None) is False # type: ignore


class TestFindTailscaleCli:
"""Tests for find_tailscale_cli function."""

@patch("dropkit.main.shutil.which", return_value="/usr/bin/tailscale")
def test_found_in_path(self, mock_which):
"""Test when tailscale is found in PATH."""
assert find_tailscale_cli() == "/usr/bin/tailscale"

@patch("dropkit.main.Path.exists", return_value=True)
@patch("dropkit.main.sys.platform", "darwin")
@patch("dropkit.main.shutil.which", return_value=None)
def test_macos_app_store_fallback(self, mock_which, mock_exists):
"""Test fallback to macOS App Store location."""
assert find_tailscale_cli() == "/Applications/Tailscale.app/Contents/MacOS/Tailscale"

@patch("dropkit.main.Path.exists", return_value=False)
@patch("dropkit.main.sys.platform", "darwin")
@patch("dropkit.main.shutil.which", return_value=None)
def test_macos_app_not_installed(self, mock_which, mock_exists):
"""Test when Tailscale is not installed on macOS."""
assert find_tailscale_cli() is None

@patch("dropkit.main.sys.platform", "linux")
@patch("dropkit.main.shutil.which", return_value=None)
def test_linux_not_in_path(self, mock_which):
"""Test when tailscale is not in PATH on Linux (no macOS fallback)."""
assert find_tailscale_cli() is None

@patch("dropkit.main.Path.exists")
@patch("dropkit.main.shutil.which", return_value="/opt/bin/tailscale")
def test_which_returns_path_skips_fallback(self, mock_which, mock_exists):
"""Test that PATH hit skips macOS App Store check."""
assert find_tailscale_cli() == "/opt/bin/tailscale"
mock_exists.assert_not_called()


class TestCheckLocalTailscale:
"""Tests for check_local_tailscale function."""

@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
@patch("dropkit.main.subprocess.run")
def test_tailscale_running(self, mock_run):
def test_tailscale_running(self, mock_run, mock_find):
"""Test when Tailscale is running locally."""
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"BackendState": "Running"}).encode("utf-8"),
)
assert check_local_tailscale() is True

@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
@patch("dropkit.main.subprocess.run")
def test_tailscale_not_running(self, mock_run):
def test_tailscale_not_running(self, mock_run, mock_find):
"""Test when Tailscale is installed but not running."""
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"BackendState": "Stopped"}).encode("utf-8"),
)
assert check_local_tailscale() is False

@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
@patch("dropkit.main.subprocess.run")
def test_tailscale_command_fails(self, mock_run):
def test_tailscale_command_fails(self, mock_run, mock_find):
"""Test when tailscale command returns non-zero."""
mock_run.return_value = MagicMock(returncode=1)
assert check_local_tailscale() is False

@patch("dropkit.main.subprocess.run")
def test_tailscale_not_installed(self, mock_run):
"""Test when tailscale is not installed."""
mock_run.side_effect = FileNotFoundError()
@patch("dropkit.main.find_tailscale_cli", return_value=None)
def test_tailscale_not_installed(self, mock_find):
"""Test when tailscale binary is not found."""
assert check_local_tailscale() is False

@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
@patch("dropkit.main.subprocess.run")
def test_tailscale_timeout(self, mock_run):
def test_tailscale_timeout(self, mock_run, mock_find):
"""Test when tailscale command times out."""
import subprocess

mock_run.side_effect = subprocess.TimeoutExpired("tailscale", 5)
assert check_local_tailscale() is False

@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
@patch("dropkit.main.subprocess.run")
def test_invalid_json_response(self, mock_run):
def test_invalid_json_response(self, mock_run, mock_find):
"""Test when tailscale returns invalid JSON."""
mock_run.return_value = MagicMock(
returncode=0,
stdout=b"not valid json",
)
assert check_local_tailscale() is False

@patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale")
@patch("dropkit.main.subprocess.run")
def test_tailscale_binary_vanishes(self, mock_run, mock_find):
"""Test race condition: binary found but gone by execution time."""
mock_run.side_effect = FileNotFoundError()
assert check_local_tailscale() is False

Comment on lines +176 to +182
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe that's a bit too much here?


class TestRunTailscaleUp:
"""Tests for run_tailscale_up function."""
Expand Down