Skip to content

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

Merged
merged 12 commits into from
Mar 18, 2025

Conversation

johnstcn
Copy link
Member

@johnstcn johnstcn commented Mar 12, 2025

  • Improves separation of concerns between runDockerInspect and convertDockerInspect: runDockerInspect now just runs the command and returns the output, while convertDockerInspect now does all of the conversion and parsing logic.
  • Improves testing of convertDockerInspect using real test fixtures.
  • Fixes issue where the container port is returned instead of the host port.
  • Updates UI to link to correct host port. Container port is still displayed in the button text, but the HostIP:HostPort is shown in a popover.
  • Adds stories for workspace agent UI

Copy link
Member

@mafredri mafredri left a 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",
Copy link
Member

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 😂

Copy link
Member Author

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))
Copy link
Member

@mafredri mafredri Mar 12, 2025

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"
    }
  ]
}

Copy link
Member

@mafredri mafredri Mar 12, 2025

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member Author

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.

@johnstcn johnstcn force-pushed the cj/agentcontainers-port-fix-2 branch from 6af8f6c to 8338af3 Compare March 13, 2025 17:35
Copy link
Member

@mafredri mafredri left a 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, ", ")))
Copy link
Member

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.

Copy link
Member Author

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;
Copy link
Collaborator

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.

Copy link
Member

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

Copy link
Member Author

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.

Comment on lines 67 to 84
<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>
Copy link
Member

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?

Copy link
Collaborator

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>

Copy link
Member

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

Copy link
Member Author

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?

Copy link
Member

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

Copy link
Member

@Parkreiner Parkreiner left a 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

Comment on lines +65 to +66
<Tooltip key={portLabel} title={helperText}>
<span>
Copy link
Member

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

Copy link
Member Author

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",
Copy link
Member

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?

Copy link
Member Author

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!,
Copy link
Member

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 accept undefined values
  • Update the render logic to render something else out if the value is missing

Copy link
Member Author

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?

Copy link
Member Author

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}
Copy link
Member

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?

Copy link
Member Author

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.

Copy link
Member

@Parkreiner Parkreiner Mar 18, 2025

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

Comment on lines +4290 to +4293
{
port: 8888,
network: "tcp",
},
Copy link
Member

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?

Copy link
Member Author

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.

@johnstcn johnstcn merged commit 75b27e8 into main Mar 18, 2025
34 of 35 checks passed
@johnstcn johnstcn deleted the cj/agentcontainers-port-fix-2 branch March 18, 2025 14:37
@github-actions github-actions bot locked and limited conversation to collaborators Mar 18, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants