Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/386.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added opt-in ephemeral Docker host ports for generated app development environments.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Set host ports to ephemeral values to avoid conflicts with other local services.
---
services:
nautobot:
ports: !override
- "8080"
docs:
ports: !override
- "8001"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incorrect. cookiecutter-based apps used 8080 as the internal port, so the for ephemeral ports, 8001 should be changed to 8080...if we want to keep the docs container listening on 8080. Alternatively, we can change it to 8001, in line with the nautobot core, and leave this file as-is.

# To expose postgres (5432), mysql (3306) on db service or redis (6379) to the host using
# ephemeral ports, uncomment the following. Ensure to match the 2 indented spaces which
# have the service nested under services.
# db:
# ports: !override
# - "5432"
# - "3306"
# redis:
# ports: !override
# - "6379"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Set Mattermost host ports to ephemeral values to avoid conflicts with other local services.
---
services:
mattermost:
ports: !override
- "8065"
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
nautobot_ver: "{{ min_nautobot_version }}"
local: false
python_ver: "3.12"
# ephemeral_ports: false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this option will not work if the "default" example is to have a list of compose files that is different than the ORIGINAL_COMPOSE_FILES (same as mysql) so we should either drop it as a suggestion or add a comment here that explains when it will work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I also tweaked the dev_environment.md file to clarify the behavior there.

compose_dir: "development"
compose_files:
- "docker-compose.base.yml"
- "docker-compose.redis.yml"
- "docker-compose.postgres.yml"
- "docker-compose.dev.yml"
# - "docker-compose.ephemeral-ports.yml"
- "mattermost/docker-compose.yml"
# - "mattermost/docker-compose.ephemeral-ports.yml"
1 change: 1 addition & 0 deletions nautobot-app/{{ cookiecutter.project_slug }}/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ invoke.yml
public
/compose.yaml
/dump.sql
/.service_ports.json
/{{ cookiecutter.app_name }}/static/{{ cookiecutter.app_name }}/docs

# Claude Code
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Set host ports to ephemeral values to avoid conflicts with other local services.
---
services:
nautobot:
ports: !override
- "8080"
docs:
ports: !override
- "8001"
# To expose postgres (5432), mysql (3306) on db service or redis (6379) to the host using
# ephemeral ports, uncomment the following. Ensure to match the 2 indented spaces which
# have the service nested under services.
# db:
# ports: !override
# - "5432"
# - "3306"
# redis:
# ports: !override
# - "6379"
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The [Invoke](http://www.pyinvoke.org/) library is used to provide some helper co
- `local`: a boolean flag indicating if invoke tasks should be run on the host or inside the docker containers (default: False, commands will be run in docker containers)
- `compose_dir`: the full path to a directory containing the project compose files
- `compose_files`: a list of compose files applied in order (see [Multiple Compose files](https://docs.docker.com/compose/extends/#multiple-compose-files) for more information)
- `ephemeral_ports`: Setting this value to `true` and not using any custom compose files will make all Nautobot containers with published ports expose themselves with dynamic ports. This is useful when running multiple Nautobot versions at the same time on the same machine so you won't experience system port conflicts. If setting `compose_files`, this will have no effect so please ensure to manually add the applicable `docker-compose.ephemeral-ports.yml` file or files to your list.

Using **Invoke** these configuration options can be overridden using [several methods](https://docs.pyinvoke.org/en/stable/concepts/configuration.html). Perhaps the simplest is setting an environment variable `INVOKE_{{ cookiecutter.app_name.upper() }}_VARIABLE_NAME` where `VARIABLE_NAME` is the variable you are trying to override. The only exception is `compose_files`, because it is a list it must be overridden in a YAML file. There is an example `invoke.yml` (`invoke.example.yml`) in this directory which can be used as a starting point.
Comment thread
gsnider2195 marked this conversation as resolved.

Expand All @@ -45,6 +46,8 @@ invoke start

The Nautobot server can now be accessed at [http://localhost:8080](http://localhost:8080) and the live documentation at [http://localhost:8001](http://localhost:8001).

When ephemeral ports are enabled, Docker assigns available host ports and the mappings are written to `.service_ports.json`. You can also inspect them with `invoke ps` or `docker compose port`, for example `docker compose port nautobot 8080`. To enable ephemeral ports with an environment variable, set `INVOKE_{{ cookiecutter.app_name.upper() }}_EPHEMERAL_PORTS=1`; to disable them, unset the environment variable, set it to an empty value, or set it to `0`.

To either stop or destroy the development environment use the following options.

- **invoke stop** - Stop the containers, but keep all underlying systems intact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
nautobot_ver: "{{ min_nautobot_version }}"
python_ver: "3.12"
# local: false
# ephemeral_ports: false
# compose_dir: "/full/path/to/{{ cookiecutter.project_slug }}/development"

# The following is an example of using MySQL as the database backend
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
- "docker-compose.redis.yml"
- "docker-compose.mysql.yml"
- "docker-compose.dev.yml"
# - "docker-compose.ephemeral-ports.yml"
72 changes: 65 additions & 7 deletions nautobot-app/{{ cookiecutter.project_slug }}/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
limitations under the License.
"""

import concurrent.futures
import json
import os
import re
import shutil
Expand All @@ -24,6 +26,14 @@
from invoke.tasks import task as invoke_task


ORIGINAL_COMPOSE_FILES = [
"docker-compose.base.yml",
"docker-compose.redis.yml",
"docker-compose.postgres.yml",
"docker-compose.dev.yml",
]


def is_truthy(arg):
"""Convert "truthy" strings into Booleans.

Expand Down Expand Up @@ -56,13 +66,9 @@ def is_truthy(arg):
"project_name": "{{ cookiecutter.app_slug }}",
"python_ver": "3.12",
"local": False,
"ephemeral_ports": False,
"compose_dir": os.path.join(os.path.dirname(__file__), "development"),
"compose_files": [
"docker-compose.base.yml",
"docker-compose.redis.yml",
"docker-compose.postgres.yml",
"docker-compose.dev.yml",
],
"compose_files": ORIGINAL_COMPOSE_FILES.copy(),
"compose_http_timeout": "86400",
}
}
Expand Down Expand Up @@ -138,6 +144,17 @@ def docker_compose(context, command, **kwargs):
compose_file_path = os.path.join(context.{{ cookiecutter.app_name }}.compose_dir, compose_file)
compose_command_tokens.append(f' -f "{compose_file_path}"')

if (
context.{{ cookiecutter.app_name }}.ephemeral_ports
and context.{{ cookiecutter.app_name }}.compose_files == ORIGINAL_COMPOSE_FILES
):
compose_file_path = os.path.join(
context.{{ cookiecutter.app_name }}.compose_dir, "docker-compose.ephemeral-ports.yml"
)
compose_command_tokens.append(
f' -f "{compose_file_path}"'
)

compose_command_tokens.append(command)

# If `service` was passed as a kwarg, add it to the end.
Expand All @@ -151,6 +168,44 @@ def docker_compose(context, command, **kwargs):
return context.run(compose_command, env=build_env, **kwargs)


@task
def dump_service_ports_to_disk(context):
"""Useful for downstream utilities without direct docker access to determine ports.

This function will sometimes be called asynchronously while containers are still
firing up, hence the `attempt` loop.
"""
service_ports = {}

for _ in range(4):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of trying multiple times? In case the services haven't come up yet?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah; it's functionally identical to the corresponding function in in the nautobot core. I've updated the docstring to also match the one used by the core, which is more explicit.

result = docker_compose(context, "ps --format json", hide=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my previous comment, it looks like we need to actually make hide=True work as it did not hide the print statements.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easy enough. Would you rather I rolled that into this PR or submitted as its own?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like just that one print statement, so I'd say roll it into this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. Command output:

Before

% pwd
/<redacted>/cookiecutter-app-test-multioutput-bug/nautobot-app-testing-app

% time invoke start -s db
Starting db in detached mode...
Running docker compose command "up --detach"
 Network testing-app_default Creating
 Network testing-app_default Created
 Container testing-app-db-1 Creating
 Container testing-app-db-1 Created
 Container testing-app-db-1 Starting
 Container testing-app-db-1 Started
Running docker compose command "ps --format json"
Running docker compose command "ps --format json"
Running docker compose command "ps --format json"
Running docker compose command "ps --format json"

invoke start -s db  0.56s user 0.24s system 3% cpu 21.093 total

After

% pwd
/<redacted>/cookiecutter-app-test-multioutput-bugfixed/nautobot-app-testing-app

% time invoke start -s db
Starting db in detached mode...
Running docker compose command "up --detach"
 Network testing-app_default Creating
 Network testing-app_default Created
 Container testing-app-db-1 Creating
 Container testing-app-db-1 Created
 Container testing-app-db-1 Starting
 Container testing-app-db-1 Started

invoke start -s db  0.57s user 0.25s system 3% cpu 21.129 total


for line in result.stdout.splitlines():
try:
service_def = json.loads(line)
service_name = re.search(
r"com\.docker\.compose\.service=(?P<service>\w+)", service_def["Labels"]
).group("service")

ports_found = {}
for port in service_def["Publishers"]:
if port.get("PublishedPort", 0):
ports_found[port["TargetPort"]] = port["PublishedPort"]

if ports_found:
service_ports[service_name] = ports_found
except (json.decoder.JSONDecodeError, AttributeError, IndexError, KeyError):
continue

if set(["nautobot", "worker"]).issubset(service_ports.keys()):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the correct list of expected services to be exposed by default? I added these changes to one of my dev environments and I saw this output:

Running docker compose command "ps --format json"
Running docker compose command "ps --format json"
Running docker compose command "ps --format json"
Running docker compose command "ps --format json"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nrnvgh this still needs changed. We don't expose the worker port by default, so this causes this loop to run all 4 times regardless. The docs service is enabled by default, but regardless I think we shouldn't expect any service except nautobot to be up to end this loop.

@nrnvgh nrnvgh Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dag nabbit. Good catch; fixed.

Before

 % time invoke start
Starting all services in detached mode...
Running docker compose command "up --detach"
 Network testing-app_default Creating

 [...]

 Container testing-app-beat-1 Started

invoke start  0.68s user 0.34s system 2% cpu 37.277 total

After

Starting all services in detached mode...
Running docker compose command "up --detach"
 Network testing-app_default Creating
 Container testing-app-worker-1 Started

 [...]

invoke start  0.37s user 0.17s system 3% cpu 16.770 total

break

sleep(5)

with open(".service_ports.json", "w", encoding="utf-8") as file:
json.dump(service_ports, file, indent=4)


def run_command(context, command, service="nautobot", **kwargs):
"""Wrapper to run a command locally or inside the nautobot container."""
if is_truthy(context.{{ cookiecutter.app_name }}.local):
Expand Down Expand Up @@ -298,7 +353,9 @@ def debug(context, service=None):
"""Start specified or all services and its dependencies in debug mode."""
service = " ".join(service) if service else ""
print(f"Starting {service or 'all services'} in debug mode...")
docker_compose(context, "up", service=service)
with concurrent.futures.ThreadPoolExecutor() as executor:
executor.submit(dump_service_ports_to_disk, context)
docker_compose(context, "up", service=service)


@task(
Expand All @@ -312,6 +369,7 @@ def start(context, service=None):
service = " ".join(service) if service else ""
print(f"Starting {service or 'all services'} in detached mode...")
docker_compose(context, "up --detach", service=service)
dump_service_ports_to_disk(context)


@task(
Expand Down
Loading