service add / deploy: --endpoint udp|tcp+udp, and show a udp service's udp:// address
Merged
JW
7d ago+311+311+012345678901234567890123456789-35-35-01234567890123456789
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--endpointon a non-computeservice addfails 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.
- Parsed locally, as in #368. A typo, a reversed pair (
- 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 emptyurlwhenendpointHostis present already covers them. The live read'sendpointis carried through (see below). - Output:
service listand theservice addline renderudp://host:port, and for tcp+udptcp://host:port, udp://host:port. Before a deploy has confirmed the host they renderudp/<port>ortcp+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.
deploytakes the scheme from the result'sendpoint. #681 returns it besideendpointHostonPOST /deployand on a live archive-deploy read, including a redeploy that passed no endpoint, where it reports the endpoint the service kept.--endpointis used only if the result has a host but noendpoint(an older platform). If neither is available,deployprintshost: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 exactlytcp://host:port`, as before.**--json: the image lane passes the result through, now includingendpoint. The archive lane addsendpointbesideendpointHost.service list --jsonpassesendpoint/endpoint_hostthrough.
Decisions
- The result's
endpointdecides 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 printtcp://. The scheme-less line appears only for an older platform that returns a host with noendpointand no flag. One #368 test row (reports a tcp deploy) now passes'tcp'explicitly, because it models a result that carries noendpoint. - 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. - Only
tcp+udpis accepted (case and whitespace normalized like the other values).udp+tcpis refused because the platform spells the pair one way only. - #368's existing tests changed in three places: the refusal message,
udpmoving 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)
| promise | test |
|---|---|
udp, tcp+udp, UDP, TCP+UDP parse | parseEndpoint: 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+udp | sends 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 deployed | three 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 tcpAddress | v6Address for tcp is tcpAddress… |
service list --json passes endpoint fields through | --json passes endpoint and endpoint_host through untouched |
| deploy body: udp / tcp+udp | rides 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 absent | host 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 http | http: the URL, unchanged rows |
| the archive body carries udp / tcp+udp and accepts an empty url with a host | sends endpoint %s and accepts an empty URL… |
the archive read carries endpoint, an older platform omits it, an unknown value is dropped | carries the read's endpoint / an older platform omits it / an unknown value is dropped |
deploy() dies on udp+tcp before ApiClient.load / requireProject | an invalid pair (udp+tcp) dies before… |
deploy() archive lane: normalized udp in the body, udp:// line, --json endpointHost | two archive lane … tests |
deploy() image lane: tcp+udp in the body, both-scheme line, --json pass-through | two image lane … tests |
deploy() image lane, plain tcp redeploy: the exact deployed img:2 -> tcp://… line, with no endpoint in the body | image lane, plain redeploy of a tcp service … |
deploy() archive lane, plain udp redeploy: udp:// from the read, and --json carries endpoint | archive 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.
| sabotage | result |
|---|---|
parseEndpoint back to http/tcp only | 15 failed |
v6Address renders tcp+udp as udp only | 2 failed (tcp+udp added line, list row) |
| list line treats only tcp as v6 | 5 failed (every udp/tcp+udp list row) |
| added line treats only tcp as v6 | 3 failed |
deployedTarget always prints tcp:// | 6 failed |
deployedTarget guesses tcp:// without --endpoint | 1 failed (the scheme-less row) |
deploy() drops the endpoint before deployedTarget | 2 failed (archive and image lane lines) |
archive body filter back to http/tcp | 3 failed |
servicesAddRequestBody drops udp / tcp+udp | 3 failed |
deployRequestBody drops udp / tcp+udp | 3 failed |
deployedTarget ignores the result's endpoint | 7 failed (all result rows, plus both plain-redeploy deploy() tests) |
| the flag wins over the result | 2 failed (both "result wins" rows) |
deployedTarget trusts an unknown result endpoint | 1 failed |
the archive read drops endpoint | 2 failed |
the archive read keeps an unknown endpoint | 1 failed |
the archive --json drops endpoint | 1 failed |
Order and companions
- Depends on instacloud-platform#681 being deployed, including its
endpointfield on the deploy result and the archive read. Before #681, the platform's schema should refuseendpoint: udp; I took that from #681's enum and did not test it. Against a platform without the resultendpointfield, the CLI falls back to the flag or to the scheme-less line, so it never prints a wrong scheme. - Command reference:
cli-reference.mdin 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
endpointargument should widen in InsForge/instacloud-mcp.
🤖 Generated with Claude Code