diff --git a/README.md b/README.md index 0d57abd..cd2f459 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ A Kaggle declaration must say `host=letify.remote`. That kernel channel is the o 2. TCP hole punching, for two machines that are both behind NAT 3. The provider's own path, such as `colab exec` and the Colab file API -A lower number wins unless it is far slower than the fastest one that connected. The winner is remembered per account and per network, so the next connection starts with it. A machine behind NAT needs letify installed and `letify agent serve` run on it once, so letify can reach it, with no SSH server. Tailcat is removed, and an account that names `tailcat` is refused with the steps to move it to `letify agent serve`. When a machine's SSH server is published to the outside under another port, such as Docker's `-p 30501:8022`, `letify login tunnel --address --public-port 30501` records it: SSH straight to the address then dials port 30501, while hole punching keeps using 8022. Colab and Elice never need an agent: their provider layer creates and opens the machine through the provider's own API. Modal is reached through its own API and is not part of this. +A lower number wins unless it is far slower than the fastest one that connected. The winner is remembered per account and per network, so the next connection starts with it. A machine behind NAT needs letify installed and `letify agent serve` run on it once, so letify can reach it, with no SSH server. Tailcat is removed, and a tunnel account that names `tailcat` is refused with the steps to move it to `letify agent serve`, any other kind that names it is told to delete the field, and `tailcat_binary` is ignored with a one-line notice. When a machine's SSH server is published to the outside under another port, such as Docker's `-p 30501:8022`, `letify login tunnel --address --public-port 30501` records it: SSH straight to the address then dials port 30501, while hole punching keeps using 8022. Colab and Elice never need an agent: their provider layer creates and opens the machine through the provider's own API. Modal is reached through its own API and is not part of this. **Multiple accounts are first class.** Each configuration entry is one account, and entries of the same kind coexist. Two Colab accounts means twice the concurrent sessions. diff --git a/docs/SPEC.md b/docs/SPEC.md index 7072f2a..60735a7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1854,7 +1854,7 @@ Forward SSH is first because it needs no remote agent and costs one connection a A strategy whose needs are not met is skipped, not attempted. A `Shell` with no rendezvous has only rank 1. Only a provider that can run a program on the machine has a rendezvous: `Colab`, `Kaggle` and `Elice`. A plain `Shell` has none, so it has forward SSH and nothing else, and a machine behind NAT is a `Tunnel` account with an agent, which is reached through letify-link and races no strategy ([Agent and pairing](#link-agent)). The connect back and TCP punch strategies stay for the providers that have a rendezvous, until letify-link replaces them ([letify-link as the default](#link-default)). An account with no `address` has no rank 1: forward SSH is skipped with the reason `no address`, and no SSH command is built for a guessed or empty address. `letify check` on such an account runs over the strategies that remain. -A configuration or account that names `tailcat`, `tailcat_port` or `tailcat_binary` is refused when its provider is built, with `ProviderUnavailable` naming the account and the field: ` names 'tailcat', but Tailcat was removed in 3.0. Run 'letify agent serve' on the machine, then 'letify login tunnel --connect letify-agent2:' with the token it prints.` Nothing falls back to another path and the field is not ignored. +An account field that named a Tailcat address to connect to, `tailcat` or `tailcat_port`, is refused when its provider is built, with `ProviderUnavailable` naming the account and the field. On a `tunnel` account the message is ` names 'tailcat', but Tailcat was removed in 3.0. Run 'letify agent serve' on the machine, then 'letify login tunnel --connect letify-agent2:' with the token it prints.` On any other kind it is ` names 'tailcat', which is obsolete since 3.0. Delete it from in `, because those kinds reach their runtime without Tailcat and have no agent to set up. `tailcat_binary` only pointed at, or disabled, a Tailcat binary and so carries no connection intent: on any kind letify ignores it and prints one line per process per alias to stderr saying the field is obsolete since 3.0 and can be deleted from the config file. That is not a fallback, because the field selects nothing in 3.0. No other field of the removed tools is handled: letify 3.0 has no `auto_install` or QUIC account option to migrate. An account reaches SSH on two ports when the machine publishes its SSH server under another port, such as a Docker container started with `-p 30501:8022`. `port` is the SSH server's port inside the machine, and the remote half of a punch splices its connections to `127.0.0.1:`. `public_port` is the port forward SSH dials at `address`. Forward SSH uses `public_port` when the account sets it, and otherwise `port`, read through `port_command` when that is set. TCP punching always uses `port`. @@ -2279,7 +2279,7 @@ Hypothesis: one long-running process holding a list of paired node ids carries a 11. **Connect.** A `tunnel` account with an `agent` field is reached through the agent, with no ssh. The account is a letify-link account without a `link` option: `link = "ssh"` on it is refused by name, because there is no ssh server to reach. It has no address, so `Tunnel.address` is `None` and no strategy is raced. `Tunnel.link()` returns the session's measurements with no ssh command, as `Colab.link()` does. `connect_link` calls `linkchannel.connect_signal`, which opens the sidecar of the account's `link.key`, builds a `SignalClient` for the account's `signal` URL and a `SignalBootstrap` for the node `agent`, and calls `connect_over` with no address, `punch=True`, the channel named `the signalling service` and no binary preparation, because the agent holds the binary of its own machine. The ping of `SignalBootstrap.start` is the bootstrap round trip, so `lead_ms` and the shim wait follow the rules of [Sidecar interface](#link-sidecar) item 8. A failure raises `ProviderUnavailable` with the reason and falls back to nothing. 12. **The ssh target.** `letify agent serve --ssh-target HOST:PORT`, or `ssh_target` in `~/.letify/agent/config.toml`, sets the sshd that the ssh stream of [Sidecar interface](#link-sidecar) item 11 reaches on this machine. It defaults to `127.0.0.1:22`. The agent has no ssh server of its own, so the stream is useful only on a machine that runs one; without one the stream is closed and nothing else is affected. 13. **What needed ssh.** Every step of a login that used ssh has a session equivalent on an agent account: the workspace check and the device query of `letify login tunnel` run over the `exec` stream (Tunnel login section), `letify login tunnel --detect-devices` asks an agent account again over the same stream and then follows [Recording devices at login](#login-records-devices) item 7, and `Tunnel.check`, the device listing and the worker listing already run over it. There is no key install or key confirmation, because the session authenticates with node keys. An agent account's `--detect-devices` is no longer refused for having no address. -14. **Tailcat is refused.** A `tunnel` account that names `tailcat`, `tailcat_port` or `tailcat_binary` is refused when its provider is built, as [Connection strategies](#connection-strategies) says: the error names the account and the field, says Tailcat was removed in 3.0 and gives the migration, `letify agent serve` on the machine and then `letify login tunnel --connect letify-agent2:`. The same field on any other account kind is refused the same way. Nothing prints a notice and keeps working. +14. **Tailcat fields.** A `tunnel` account that names `tailcat` or `tailcat_port` is refused when its provider is built, as [Connection strategies](#connection-strategies) says: the error names the account and the field, says Tailcat was removed in 3.0 and gives the migration, `letify agent serve` on the machine and then `letify login tunnel --connect letify-agent2:`. The same fields on any other account kind are refused with the obsolete-since-3.0 text and no agent migration. `tailcat_binary` on any kind is ignored with a one-line notice. #### NAT traversal diff --git a/docs/guide/02-providers.md b/docs/guide/02-providers.md index 05aa706..ff33c6b 100644 --- a/docs/guide/02-providers.md +++ b/docs/guide/02-providers.md @@ -191,7 +191,7 @@ signal = "https://letify.darkpyonix.dev" ### A Tailcat account -Tailcat was removed in 3.0. An account that names `tailcat`, `tailcat_port` or `tailcat_binary` is refused, and the message names the account and the field. To migrate it, run `letify agent serve` on the machine and run `letify login tunnel home_box --connect letify-agent2:...` with the token it prints. Use `--replace` to renew an account that is already declared. +Tailcat was removed in 3.0. An account that names `tailcat` or `tailcat_port` is refused, and the message names the account and the field. On a `tunnel` account the message gives the migration, and on any other kind it says the field is obsolete since 3.0 and to delete it from the config file. `tailcat_binary` is ignored on every kind, with one line printed per alias saying it can be deleted. To migrate a tunnel account, run `letify agent serve` on the machine and run `letify login tunnel home_box --connect letify-agent2:...` with the token it prints. Use `--replace` to renew an account that is already declared. Try the simpler paths first. A direct address, then a jump host, then this. Campus machines often allow one of the first two. Details and measurements are in [docs/NETWORK.md](../NETWORK.md). diff --git "a/docs/guide/ko/02-\355\224\204\353\241\234\353\260\224\354\235\264\353\215\224.md" "b/docs/guide/ko/02-\355\224\204\353\241\234\353\260\224\354\235\264\353\215\224.md" index d0827aa..600ac1e 100644 --- "a/docs/guide/ko/02-\355\224\204\353\241\234\353\260\224\354\235\264\353\215\224.md" +++ "b/docs/guide/ko/02-\355\224\204\353\241\234\353\260\224\354\235\264\353\215\224.md" @@ -191,7 +191,7 @@ signal = "https://letify.darkpyonix.dev" ### Tailcat 계정 -Tailcat은 3.0에서 제거되었습니다. `tailcat`, `tailcat_port`, `tailcat_binary`를 적은 계정은 거부되고, 메시지에 계정과 필드 이름이 나옵니다. 옮기려면 머신에서 `letify agent serve`를 실행하고, 출력된 토큰으로 `letify login tunnel home_box --connect letify-agent2:...`를 실행하세요. 이미 선언된 계정을 갱신하려면 `--replace`를 붙입니다. +Tailcat은 3.0에서 제거되었습니다. `tailcat`, `tailcat_port`를 적은 계정은 거부되고, 메시지에 계정과 필드 이름이 나옵니다. `tunnel` 계정에는 옮기는 방법이 나오고, 다른 종류의 계정에는 3.0부터 쓰지 않는 필드이니 설정 파일에서 지우라는 안내가 나옵니다. `tailcat_binary`는 모든 종류에서 무시되며, 계정마다 지워도 된다는 한 줄만 출력됩니다. tunnel 계정을 옮기려면 머신에서 `letify agent serve`를 실행하고, 출력된 토큰으로 `letify login tunnel home_box --connect letify-agent2:...`를 실행하세요. 이미 선언된 계정을 갱신하려면 `--replace`를 붙입니다. 더 단순한 경로를 먼저 시도하세요. 직접 주소, 그다음 점프 호스트, 그다음 터널입니다. 학교 머신은 앞의 두 개가 되는 경우가 많습니다. 상세한 측정치는 [docs/NETWORK.md](../../NETWORK.md)에 있습니다. diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index 768a7f2..ca5edca 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -297,7 +297,7 @@ Kaggle 선언에는 `host=letify.remote`를 써야 합니다. Kaggle은 인바 2. TCP 홀펀칭, 양쪽이 모두 NAT 뒤에 있을 때 3. 프로바이더 자체 경로, 예를 들어 `colab exec`와 Colab 파일 API -번호가 낮은 방법이 이깁니다. 다만 연결된 방법 중 가장 빠른 것보다 훨씬 느리면 탈락합니다. 이긴 방법은 계정과 네트워크별로 기억해 두고, 다음 연결에서 먼저 시도합니다. NAT 뒤에 있는 머신은 letify를 설치한 뒤 그 머신에서 `letify agent serve`를 한 번 실행하면 letify가 접속할 수 있고, SSH 서버가 필요 없습니다. Tailcat은 제거되었고, `tailcat`을 적은 계정은 `letify agent serve`로 옮기는 방법과 함께 거부됩니다. 머신의 SSH 서버가 Docker의 `-p 30501:8022`처럼 다른 포트로 외부에 열려 있다면 `letify login tunnel --address --public-port 30501`로 기록합니다. 그러면 주소로 바로 하는 SSH는 30501 포트로 접속하고, 홀펀칭은 계속 8022를 씁니다. Colab과 Elice는 에이전트가 아예 필요 없습니다. 프로바이더 계층이 각 서비스의 API로 머신을 만들고 엽니다. Modal은 자체 API로 연결하므로 여기에 해당하지 않습니다. +번호가 낮은 방법이 이깁니다. 다만 연결된 방법 중 가장 빠른 것보다 훨씬 느리면 탈락합니다. 이긴 방법은 계정과 네트워크별로 기억해 두고, 다음 연결에서 먼저 시도합니다. NAT 뒤에 있는 머신은 letify를 설치한 뒤 그 머신에서 `letify agent serve`를 한 번 실행하면 letify가 접속할 수 있고, SSH 서버가 필요 없습니다. Tailcat은 제거되었고, `tailcat`을 적은 tunnel 계정은 `letify agent serve`로 옮기는 방법과 함께 거부되고, 다른 종류의 계정은 그 필드를 지우라는 안내와 함께 거부되며, `tailcat_binary`는 한 줄 안내만 출력하고 무시됩니다. 머신의 SSH 서버가 Docker의 `-p 30501:8022`처럼 다른 포트로 외부에 열려 있다면 `letify login tunnel --address --public-port 30501`로 기록합니다. 그러면 주소로 바로 하는 SSH는 30501 포트로 접속하고, 홀펀칭은 계속 8022를 씁니다. Colab과 Elice는 에이전트가 아예 필요 없습니다. 프로바이더 계층이 각 서비스의 API로 머신을 만들고 엽니다. Modal은 자체 API로 연결하므로 여기에 해당하지 않습니다. **여러 계정을 정식으로 지원합니다.** 설정 항목 하나가 계정 하나이고, 같은 종류를 여러 개 둘 수 있습니다. Colab 계정이 두 개면 동시 세션도 두 배가 됩니다. diff --git a/letify/providers/base.py b/letify/providers/base.py index eccecd8..818c871 100644 --- a/letify/providers/base.py +++ b/letify/providers/base.py @@ -26,6 +26,7 @@ import abc import dataclasses +import sys import threading from collections.abc import Iterator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal @@ -40,6 +41,7 @@ UnknownInstance, UnsupportedMode, ) +from ..paths import config_label from .usage import Usage, from_command if TYPE_CHECKING: @@ -63,9 +65,16 @@ class RuntimeState: detail: str = "" -#: Account fields of the removed Tailcat connection. An account that names one is refused. -#: Spec "Connection strategies". -REMOVED_TAILCAT_FIELDS = ("tailcat", "tailcat_port", "tailcat_binary") +#: Account fields that named a Tailcat address to connect to. An account that names one is +#: refused. Spec "Connection strategies". +REMOVED_TAILCAT_FIELDS = ("tailcat", "tailcat_port") + +#: The field that only pointed at, or disabled, a Tailcat binary. It selects nothing in 3.0, +#: so it is ignored with a notice. Spec "Connection strategies". +OBSOLETE_TAILCAT_FIELD = "tailcat_binary" + +#: The aliases already told that ``tailcat_binary`` is obsolete, one notice per process. +_NOTIFIED: set[str] = set() class Provider(abc.ABC): @@ -106,13 +115,26 @@ class Provider(abc.ABC): def __init__(self, config: ProviderConfig): for removed in REMOVED_TAILCAT_FIELDS: if removed in config.options: + if self.kind == "tunnel": + raise ProviderUnavailable( + self.kind, + f"{config.alias} names {removed!r}, but Tailcat was removed in 3.0. " + f"Run 'letify agent serve' on the machine, then " + f"'letify login tunnel {config.alias} --connect letify-agent2:' " + f"with the token it prints", + ) raise ProviderUnavailable( self.kind, - f"{config.alias} names {removed!r}, but Tailcat was removed in 3.0. " - f"Run 'letify agent serve' on the machine, then " - f"'letify login tunnel {config.alias} --connect letify-agent2:' " - f"with the token it prints", + f"{config.alias} names {removed!r}, which is obsolete since 3.0. Delete " + f"it from {config.alias} in {config_label()}", ) + if OBSOLETE_TAILCAT_FIELD in config.options and config.alias not in _NOTIFIED: + _NOTIFIED.add(config.alias) + print( + f"{config.alias}: {OBSOLETE_TAILCAT_FIELD!r} is obsolete since 3.0 and is " + f"ignored. Delete it from {config.alias} in {config_label()}", + file=sys.stderr, + ) self.config = config self.alias = config.alias self._instances: dict[str, Instance] | None = None diff --git a/tests/test_tailcat_removed.py b/tests/test_tailcat_removed.py index 13dfc57..980e507 100644 --- a/tests/test_tailcat_removed.py +++ b/tests/test_tailcat_removed.py @@ -36,10 +36,9 @@ def old_token(**fields) -> str: {"tailcat": "tc1abc", "tailcat_port": 4000}, {"tailcat": "tc1abc"}, {"tailcat_port": 4000}, - {"tailcat_binary": "/usr/bin/tailcat"}, ], ) -def test_an_account_that_names_a_tailcat_field_is_refused_by_name( +def test_an_account_that_names_a_tailcat_address_is_refused_by_name( isolated_home, cls, options ) -> None: field = next(iter(options)) @@ -48,9 +47,36 @@ def test_an_account_that_names_a_tailcat_field_is_refused_by_name( message = str(raised.value) assert "box" in message assert f"'{field}'" in message - assert "Tailcat was removed in 3.0" in message - assert "letify agent serve" in message - assert "letify login tunnel box --connect letify-agent2:" in message + assert "obsolete since 3.0" in message or "Tailcat was removed in 3.0" in message + if cls is Tunnel: + assert "Tailcat was removed in 3.0" in message + assert "letify agent serve" in message + assert "letify login tunnel box --connect letify-agent2:" in message + else: + assert "obsolete since 3.0" in message + assert "Delete" in message + assert "letify agent serve" not in message + assert "letify login tunnel" not in message + + +@pytest.mark.parametrize("cls", [Shell, Tunnel, Colab, Kaggle, Elice]) +def test_tailcat_binary_is_ignored_with_one_notice_per_alias( + isolated_home, cls, capsys, monkeypatch +) -> None: + from letify.providers import base + + monkeypatch.setattr(base, "_NOTIFIED", set()) + options = {"tailcat_binary": "tailcat-disabled-for-this-account"} + assert provider_of(cls, "box", **options).alias == "box" + provider_of(cls, "box", **options) + err = capsys.readouterr().err + assert err.count("tailcat_binary") == 1 + assert "box" in err + assert "obsolete since 3.0" in err + assert "Delete" in err + assert err.count("\n") == 1 + provider_of(cls, "other", **options) + assert "other" in capsys.readouterr().err def test_an_account_without_a_tailcat_field_is_built(isolated_home) -> None: