service add / deploy: --endpoint udp|tcp+udp, and show a udp service's udp:// address

Merged
JW
7d ago
+311-35

The CLI half of InsForge/instacloud-platform#681, which widens compute endpoint from http | tcp to http | tcp | udp | tcp+udp. The compute plane has served public UDP on its v6 lane in every region since 2026-10-14. This PR follows the shape of #368 (--endpoint tcp).

insta service add compute dns --image my/dns --port 5000 --endpoint udp
# added compute service dns … — udp/5000 (address after the first deploy)
insta deploy --image my/dns:2 --port 5000 --endpoint tcp+udp
# deployed my/dns:2 -> tcp://<host>:5000, udp://<host>:5000 (branch main, group dns)

Changes

  • service add / deploy: --endpoint <http|tcp|udp|tcp+udp>.
    • Parsed locally, as in #368. A typo, a reversed pair (udp+tcp), or --endpoint on a non-compute service add fails before any network call.
    • Sent only when given, so every existing invocation sends the same body as before.
    • The CLI does not check ports, the same as tcp in #368: the platform owns the tcp and udp denylists. A udp-denied port such as 53 is sent as given and refused by the platform. A test pins this.
  • Help text (where #368 put the tcp notes): IPv6 clients only, no platform TLS, no custom domain. Refused ports are described by kind: amplification-prone (DNS, NTP, SNMP, memcached, …) and platform-reserved ports; tcp+udp refuses both lists. A datagram wakes a suspended service, and what the wake held is re-sent until the service first replies, so an app may see a datagram twice. A receive-only udp service needs --always-on.
  • Archive (source-dir) lane: the one gated body carries udp / tcp+udp. #368's check that accepts an empty url when endpointHost is present already covers them. The live read's endpoint is carried through (see below).
  • Output:
    • service list and the service add line render udp://host:port, and for tcp+udp tcp://host:port, udp://host:port. Before a deploy has confirmed the host they render udp/<port> or tcp+udp/<port> (address after the first deploy).
    • A v6 row never shows an HTTP domain.
    • tcp and http rows are byte-for-byte unchanged, and tests pin the exact lines.
    • deploy takes the scheme from the result's endpoint. #681 returns it beside endpointHost on POST /deploy and on a live archive-deploy read, including a redeploy that passed no endpoint, where it reports the endpoint the service kept. --endpoint is used only if the result has a host but no endpoint (an older platform). If neither is available, deploy prints host:port (endpoint unchanged; \insta service list` shows its protocol)rather than guessingtcp://. An endpoint value this CLI does not know is treated as absent. **A plain redeploy of a tcp service prints exactly tcp://host:port`, as before.**
    • --json: the image lane passes the result through, now including endpoint. The archive lane adds endpoint beside endpointHost. service list --json passes endpoint / endpoint_host through.

Decisions

  1. The result's endpoint decides the scheme, and the flag is only a fallback. The result reports the endpoint the service actually ended up on. Without it, a plain redeploy of a udp service would print tcp://. The scheme-less line appears only for an older platform that returns a host with no endpoint and no flag. One #368 test row (reports a tcp deploy) now passes 'tcp' explicitly, because it models a result that carries no endpoint.
  2. The tcp+udp rendering is tcp://h:p, udp://h:p: one scheme per protocol, so each part is a valid URI on its own. tcp+udp:// is not a real scheme.
  3. Only tcp+udp is accepted (case and whitespace normalized like the other values). udp+tcp is refused because the platform spells the pair one way only.
  4. #368's existing tests changed in three places: the refusal message, udp moving from the refused list to the accepted one, and the tcp deploy-target row above. These follow from the contract change; nothing was loosened.

Promise → test (test/endpoint-udp.test.ts, one row per outcome)

promisetest
udp, tcp+udp, UDP, TCP+UDP parseparseEndpoint: udp and tcp+udp › %j is %s
udp+tcp, tcp+, tcp,udp, tcp udp, quic refused, with the message… › %j is refused
service add sends udp / tcp+udpsends endpoint %s
no CLI port mirror (53 + udp is sent)does not mirror the platform port list
udp on redis, tcp+udp on storage, and a reversed pair all fail locally%s fails locally
added line: udp, tcp+udp, and not yet deployedthree the added line … tests
list: udp, tcp+udp, both not yet deployed, a stray domain ignored, tcp and http unchanged (exact lines)service list: udp and tcp+udp rows table
v6Address for tcp is exactly tcpAddressv6Address for tcp is tcpAddress…
service list --json passes endpoint fields through--json passes endpoint and endpoint_host through untouched
deploy body: udp / tcp+udprides the deploy body as %s
result endpoint tcp / udp / tcp+udp with no flag (tcp is the exact tcp:// line)deployedTarget reads the endpoint from the result › result … no flag
when the flag and the result disagree, the result wins (two rows)… the result wins
host without endpoint falls back to the flag, or to no scheme; an unknown result value counts as absenthost without endpoint … / … does not know …
older platform: the flag gives udp or tcp+udp; no --port prints :?reports %s table
http: the URL is unchanged, with or without --endpoint httphttp: the URL, unchanged rows
the archive body carries udp / tcp+udp and accepts an empty url with a hostsends endpoint %s and accepts an empty URL…
the archive read carries endpoint, an older platform omits it, an unknown value is droppedcarries the read's endpoint / an older platform omits it / an unknown value is dropped
deploy() dies on udp+tcp before ApiClient.load / requireProjectan invalid pair (udp+tcp) dies before…
deploy() archive lane: normalized udp in the body, udp:// line, --json endpointHosttwo archive lane … tests
deploy() image lane: tcp+udp in the body, both-scheme line, --json pass-throughtwo image lane … tests
deploy() image lane, plain tcp redeploy: the exact deployed img:2 -> tcp://… line, with no endpoint in the bodyimage lane, plain redeploy of a tcp service …
deploy() archive lane, plain udp redeploy: udp:// from the read, and --json carries endpointarchive lane, plain redeploy of a udp service …

Gates

  • npm run typecheck: clean.
  • npm test: 105 files, 2225 tests passed.

Negative controls

I broke each piece alone and ran the full suite. Each run failed where expected, then the file was restored with git checkout, and git diff --quiet confirmed the restore was exact.

sabotageresult
parseEndpoint back to http/tcp only15 failed
v6Address renders tcp+udp as udp only2 failed (tcp+udp added line, list row)
list line treats only tcp as v65 failed (every udp/tcp+udp list row)
added line treats only tcp as v63 failed
deployedTarget always prints tcp://6 failed
deployedTarget guesses tcp:// without --endpoint1 failed (the scheme-less row)
deploy() drops the endpoint before deployedTarget2 failed (archive and image lane lines)
archive body filter back to http/tcp3 failed
servicesAddRequestBody drops udp / tcp+udp3 failed
deployRequestBody drops udp / tcp+udp3 failed
deployedTarget ignores the result's endpoint7 failed (all result rows, plus both plain-redeploy deploy() tests)
the flag wins over the result2 failed (both "result wins" rows)
deployedTarget trusts an unknown result endpoint1 failed
the archive read drops endpoint2 failed
the archive read keeps an unknown endpoint1 failed
the archive --json drops endpoint1 failed

Order and companions

  • Depends on instacloud-platform#681 being deployed, including its endpoint field on the deploy result and the archive read. Before #681, the platform's schema should refuse endpoint: udp; I took that from #681's enum and did not test it. Against a platform without the result endpoint field, the CLI falls back to the flag or to the scheme-less line, so it never prints a wrong scheme.
  • Command reference: cli-reference.md in InsForge/instacloud-skills (AGENTS rule 4) will follow in its own PR, as #368's did.
  • MCP: this adds new values to an existing flag on existing leaves, not a new leaf, so rule 6 does not apply. The MCP endpoint argument should widen in InsForge/instacloud-mcp.

🤖 Generated with Claude Code