-
Notifications
You must be signed in to change notification settings - Fork 887
fix(agent/agentcontainers): improve testing of convertDockerInspect, return correct host port #16887
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes more sense than the previous PR, hehe. Some minor nits and one bigger concern about the placement of "seen host ports". Other than that, LGTM.
FriendlyName: "optimistic_hopper", | ||
Image: "debian:bookworm", | ||
Labels: map[string]string{ | ||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_simple.json", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A little bit of coder inflation in this path 😂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TBH there's probably a lot of "pruning" we can do in those fixtures, but I'd like to avoid changing them if possible :)
sort.Strings(portKeys) | ||
// Each port binding may have multiple entries mapped to the same interface. | ||
// Keep track of the ports we've already seen. | ||
seen := make(map[int]struct{}, len(in.NetworkSettings.Ports)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel this seen map, as well as port info, is now a bit misplaced when we're looking at the HostPort and not the container port. This port is under container, but is a host port, so it may appear on multiple containers which would be confusing.
Since we're throwing away the information where this port maps to, it could be container X or Y depending on if it's listening on IPv4 or IPv6, for instance. Thus it would be listed on both container responses but likely only one of those will be reachable.
TL;DR, seen host ports are better placed outside the container structure, on the parent response.
❯ docker run -it --rm --name alpy1 -p 127.0.0.1:8181:8181 alpine
❯ docker run -it --rm --name alpy2 -p :::8181:8182 alpine
❯ docker inspect alpy1 alpy2 | jq '.[] | .Name, .NetworkSettings.Ports'
"/alpy1"
{
"8181/tcp": [
{
"HostIp": "127.0.0.1",
"HostPort": "8181"
}
]
}
"/alpy2"
{
"8182/tcp": [
{
"HostIp": "::",
"HostPort": "8181"
}
]
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addendum: As a further improvement, I would suggest that this data parsing should also decide (or output if predetermined) which container/port the host port maps to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now having HostIp
doesn't seem to be useful to the UI for the purposes of accessing applications listening inside the workspace.
Let's say you have two containers, both listening on the same port but on different interfaces:
docker run --name nginx-a -v /path/to/some/html/a:/usr/share/nginx/html:ro -d -p 127.0.0.1:8000:80 nginx
docker run --name nginx-b -v /path/to/some/html/b:/usr/share/nginx/html:ro -d -p [::1]:8000:80 nginx
When I hit the forwarded port in the Coder UI, it hits the container listening on IPv4.
Similarly, when I curl http://localhost:8000
it also hits the container listening on IPv4. I needed to explicitly specify curl -6 http://localhost:8000
to hit the container listening on the IPv6 interface.
If I stopped either the ipv4
or ipv6
container, it would fall back to whichever one was running.
Whether or not the v4 or v6 address is preferred seems to be down to the underlying OS configuration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense, thanks for investigating. So ultimately, this confirms we can't with confidence say which container a HostPort
belongs to.
I think the best option for us now is to detect that multiple containers have the same host port mapped, and if that's the case, we flag it via additional parameter. Perhaps (simplified) "conflicts": ["container2:9090/tcp"]
or something like that? (Likewise container2 should have "conflicts": ["container1:8080/tcp"]
.)
This would allow us to surface a ?
in the UI, saying "this could go to either container1 port 8080 or container2 port 9090.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternative suggestion: a message to this effect can be dropped into warnings
as a relatively cheap and easy way to surface this situation. I don't think it's going to be that common.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I've added a test case for this scenario and hopefully improved clarity overall.
… type, return container and host port
6af8f6c
to
8338af3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't review the frontend part in detail but everything else looks great! Thanks for working on ironing out the edge cases here!
// Check if any host ports are mapped to multiple containers. | ||
for hp, ids := range hostPortContainers { | ||
if len(ids) > 1 { | ||
warns = append(warns, fmt.Sprintf("host port %d is mapped to multiple containers on different interfaces: %s", hp, strings.Join(ids, ", "))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is ids here the sha or a human readable name? The latter may be easier on the eyes but both work as long as we surface the used value in the UI.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used the ID here for specificity. FriendlyName might be a good call; I'll address that in a follow-up!
port.host_port !== undefined && | ||
port.host_port !== null && | ||
port.host_ip !== undefined && | ||
port.host_ip !== null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm finding this quite strange. Why not only return from the API null or undefined? Having the API returning both types look strange to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this seems like it was meant to be double-bookkeeping, but with how the server types are set up, the null
conditionals won't ever trigger. The omitEmpty
JSON tag will make sure that if the values of host_ip
or host_port
are missing, they'll always evaluate to undefined
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Will remove the extraneous null
checks.
<Link | ||
key={portLabel} | ||
color="inherit" | ||
component={AgentButton} | ||
underline="none" | ||
startIcon={<ExternalLinkIcon className="size-icon-sm" />} | ||
disabled={!hasHostBind} | ||
href={portForwardURL( | ||
wildcardHostname, | ||
port.host_port!, | ||
agentName, | ||
workspace.name, | ||
workspace.owner_name, | ||
location.protocol === "https" ? "https" : "http", | ||
)} | ||
> | ||
{portLabel} | ||
</Link> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@BrunoQuaresma Have we switched over to Shad for link variants like this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It depends. I would not expect BE folks migrating the components to be honest, but in this case I think would make more sense to replace them by the new button component like this:
<Button asChild>
<a href="..." onClick={...} />
</Button>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh wait, sorry – got mixed up by the diff. Didn't realize the link was already here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest we make this a follow-up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's totally fine with me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had a few more comments. Nothing major, but they still had me scratching my head
<Tooltip key={portLabel} title={helperText}> | ||
<span> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two questions:
- Is there a reason why this code is using the MUI version of the tooltip, rather than the one we have in our components folder?
- Is the span here doing anything? I could see needing it to change some flexbox behavior, but I don't see anything obvious that would require it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason why this code is using the MUI version of the tooltip, rather than the one we have in our components folder?
Copy-pasta, most likely!
Is the span here doing anything? I could see needing it to change some flexbox behavior, but I don't see anything obvious that would require it
The tooltip wouldn't display if the link was disabled; the <span>
was a random StackOverflow find.
agentName, | ||
workspace.name, | ||
workspace.owner_name, | ||
location.protocol === "https" ? "https" : "http", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
location.protocol
should always be either https
or http
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct.
disabled={!hasHostBind} | ||
href={portForwardURL( | ||
wildcardHostname, | ||
port.host_port!, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we making a non-nullable assertion for host_port
, when just a few lines above, we're checking whether it's defined? I'd rather do one of two things:
- Update
portForwardURL
to acceptundefined
values - Update the render logic to render something else out if the value is missing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update the render logic to render something else out if the value is missing
Do you have any suggestions for an alternative "nowhere" URL? If there is no host port, we can't forward in the Coder UI. Maybe about:blank
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defaulting href to an empty string if the link is disabled.
component={AgentButton} | ||
underline="none" | ||
startIcon={<ExternalLinkIcon className="size-icon-sm" />} | ||
disabled={!hasHostBind} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the value of hasHostBind
likely to change over time? If I'm reading this right, it feels weird to me that we could be rendering out a link that is always disabled. At that point, why make it an interactive element in the first place?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could change if the user bound the remote port to a local port and rebuilt the container. My reasoning for including the non-interactive variants was that if they simply didn't show up in the UI, it would be more confusing to users. Having it show up with a tooltip explaining why it was not enabled felt "right" to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think that's a solid solution. I guess I'm just usually a little more skittish about disabled UI elements for accessibility reasons. But if/when that becomes a priority, I think it should be easy to update the component
{ | ||
port: 8888, | ||
network: "tcp", | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just making sure: is it possible for there to be variants of the port value that have host_port
but not host_ip
, or vice-versa?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it likely in practice.
runDockerInspect
andconvertDockerInspect
:runDockerInspect
now just runs the command and returns the output, whileconvertDockerInspect
now does all of the conversion and parsing logic.convertDockerInspect
using real test fixtures.