Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
6b61771
Add Kubernetes Agent E2E backend
nubtron Jul 22, 2026
496b3c2
Use draft PR number for changelogs
nubtron Jul 22, 2026
d4e2ba3
Remove unused Velero Kind port mapping
nubtron Jul 22, 2026
0ae3476
Use shared named-port discovery helper
nubtron Jul 22, 2026
9711d66
Test generated discovery behavior
nubtron Jul 22, 2026
0065786
Test generated discovery through public contract
nubtron Jul 22, 2026
9f63a54
Extract Velero migration into stacked PR
nubtron Jul 22, 2026
23d1947
Encapsulate Agent backend startup policy
nubtron Jul 22, 2026
04d2bc5
Add Kubernetes discovery candidate stability helper
nubtron Jul 22, 2026
fb00f3e
Document Kubernetes candidate stability support
nubtron Jul 22, 2026
154ce6a
Clean up unsupported CI environments
nubtron Jul 23, 2026
9b88ee8
Clarify Agent interface creation
nubtron Jul 23, 2026
5206dbd
refactor(e2e): move Kubernetes discovery helper to consumer PR
nubtron Jul 23, 2026
a65f921
refactor(e2e): avoid caching Kubernetes Agent properties
nubtron Jul 23, 2026
aad4996
Restore unsupported CI backend state handling
nubtron Jul 23, 2026
d0dbbf8
Clarify Kubernetes Agent constraints
nubtron Jul 23, 2026
45d98e2
refactor(ddev): use singular Agent build config keys
nubtron Jul 23, 2026
c6b694d
Remove Kubernetes Agent pod label option
nubtron Jul 23, 2026
18267e7
Remove custom Kubernetes Agent namespace option
nubtron Jul 23, 2026
475a6e9
Deduplicate Agent log commands
nubtron Jul 23, 2026
c28ca04
Simplify Kubernetes metadata access
nubtron Jul 23, 2026
e0d4f0d
refactor(ddev): remove Kubernetes Agent owner ID
nubtron Jul 23, 2026
79f0f9f
Simplify Kubernetes Agent cluster ownership
nubtron Jul 23, 2026
cc84c31
Update logs tests to Python 3.13
nubtron Jul 23, 2026
4c14f0e
Refine Kubernetes command contract tests
nubtron Jul 23, 2026
449f6c1
Test Kubernetes topology command directly
nubtron Jul 23, 2026
79e4a9d
Reject non-Kind Kubernetes Agent contexts
nubtron Jul 23, 2026
b530ccd
Relax Kubernetes orchestration assertions
nubtron Jul 23, 2026
3e36fe4
Update Kubernetes Agent description for E2E tests
nubtron Jul 24, 2026
1742361
Simplify Kubernetes local package metadata
nubtron Jul 24, 2026
7acae1a
Merge remote-tracking branch 'origin/nubtron/kubernetes-agent-interfa…
nubtron Jul 24, 2026
bb32463
Restore environment state cleanup ordering
nubtron Jul 24, 2026
fe768f8
Wait for Kubernetes Agent before restart
nubtron Jul 24, 2026
169801d
Fail fast on unsupported Agent supervision
nubtron Jul 24, 2026
3806d7c
Simplify Kubernetes Agent restart checks
nubtron Jul 24, 2026
46fc2c7
Keep Kubernetes Agent restarts repeatable
nubtron Jul 24, 2026
ba5f7ae
Detect Kubernetes Agent container state loss
nubtron Jul 24, 2026
06de7e5
Rename Kubernetes Agent module constants
nubtron Jul 24, 2026
09e2d72
Update Kubernetes node support description
nubtron Jul 24, 2026
6f17438
Decouple Kubernetes Agent tests from commands
nubtron Jul 24, 2026
2bb169d
Expose shared Agent image normalization
nubtron Jul 24, 2026
ed46882
Require a Kubernetes metadata mapping
nubtron Jul 30, 2026
4eb882d
Stamp the prepared marker before restarting the Agent
nubtron Jul 30, 2026
9cd9a3d
Share Agent image normalization through the interface
nubtron Jul 30, 2026
63e2121
Clarify the prepared marker invariant
nubtron Jul 30, 2026
5c6018f
Merge branch 'master' into nubtron/kubernetes-agent-interface-e2e
nubtron Jul 30, 2026
6fc9574
Move Agent image normalization to a shared module
nubtron Jul 30, 2026
1aa6729
Fail fast on unsupported Kubernetes Agent inputs
nubtron Aug 3, 2026
cd499d3
Clarify shared-log backend limitation
nubtron Aug 3, 2026
00d1ea0
Preserve Agent check failures during config cleanup
nubtron Aug 3, 2026
1039881
Merge unsupported-options documentation update
nubtron Aug 3, 2026
76ed941
Clarify current Kubernetes Agent limitations
nubtron Aug 3, 2026
af9b11e
Merge unsupported-options wording update
nubtron Aug 3, 2026
99056e4
Keep kubectl warnings out of structured output
nubtron Aug 3, 2026
32b6e2f
Merge config-cleanup stack update
nubtron Aug 3, 2026
f21cf4c
Merge config-cleanup stack update
nubtron Aug 3, 2026
f35fe99
Run Calico E2E with Kubernetes Agent
nubtron Jul 23, 2026
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
59 changes: 40 additions & 19 deletions calico/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,49 @@

import pytest

from datadog_checks.dev.conditions import CheckEndpoints, WaitFor
from datadog_checks.dev.conditions import WaitFor
from datadog_checks.dev.kind import kind_run
from datadog_checks.dev.kube_port_forward import port_forward
from datadog_checks.dev.subprocess import run_command

from .common import EXTRA_METRICS

NAMESPACE = "calico"
HERE = path.dirname(path.abspath(__file__))
FELIX_METRICS_ENDPOINT = 'http://felix-metrics-svc.kube-system.svc.cluster.local:9091/metrics'


def _felix_config_default_exists():
result = run_command(["kubectl", "get", "felixconfiguration", "default"], capture='both')
return result.code == 0


def felix_metrics_available() -> bool:
result = run_command(
[
"kubectl",
"run",
"felix-metrics-readiness",
"--namespace",
"kube-system",
"--image=busybox:1.36.1",
"--restart=Never",
"--attach",
"--rm",
"--quiet",
"--",
"wget",
"-q",
"-T",
"2",
"-O",
"/dev/null",
FELIX_METRICS_ENDPOINT,
],
capture='both',
)
return result.code == 0


def setup_calico():
# Deploy calico
run_command(["kubectl", "apply", "-f", path.join(HERE, 'kind', 'calico.yaml')])
Expand Down Expand Up @@ -60,29 +87,23 @@ def setup_calico():
check=True,
)

# Check from a temporary pod because the host cannot resolve Kubernetes Service DNS.
WaitFor(felix_metrics_available, wait=2, attempts=100)()


@pytest.fixture(scope='session')
def dd_environment():
with (
kind_run(
conditions=[setup_calico], kind_config=path.join(HERE, 'kind', 'kind-calico.yaml'), sleep=10
) as kubeconfig,
port_forward(kubeconfig, 'kube-system', 9091, 'service', 'felix-metrics-svc') as (
calico_host,
calico_port,
),
):
endpoint = 'http://{}:{}/metrics'.format(calico_host, calico_port)

# We can't add this to `kind_run` because we don't know the URL at this moment
condition = CheckEndpoints(endpoint, wait=2, attempts=100)
condition()

yield {
"openmetrics_endpoint": endpoint,
with kind_run(
conditions=[setup_calico], kind_config=path.join(HERE, 'kind', 'kind-calico.yaml'), sleep=10
) as kubeconfig:
instance = {
"openmetrics_endpoint": FELIX_METRICS_ENDPOINT,
"namespace": NAMESPACE,
"extra_metrics": EXTRA_METRICS,
}
metadata = {'agent_type': 'kubernetes', 'kubernetes': {'kubeconfig': kubeconfig}}

yield instance, metadata


@pytest.fixture
Expand Down
1 change: 1 addition & 0 deletions datadog_checks_dev/changelog.d/24639.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add backend-neutral Agent log retrieval for E2E test diagnostics.
5 changes: 4 additions & 1 deletion datadog_checks_dev/datadog_checks/dev/plugin/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,10 @@ def run_check(config=None, **kwargs):

if not matches:
message_parts = []
debug_result = run_command(['docker', 'logs', 'dd_{}_{}'.format(check, env)], capture=True)
debug_result = run_command(
[python_path, '-m', 'ddev', 'env', 'logs', check, env],
capture=True,
)
if not debug_result.code:
message_parts.append(debug_result.stdout + debug_result.stderr)

Expand Down
1 change: 1 addition & 0 deletions ddev/changelog.d/24639.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a Kubernetes Agent interface for running the Agent in Kind-based E2E test clusters, alongside the existing Docker and Vagrant interfaces.
2 changes: 2 additions & 0 deletions ddev/src/ddev/cli/env/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ddev.cli.env.agent import agent
from ddev.cli.env.check import check
from ddev.cli.env.config import config
from ddev.cli.env.logs import logs
from ddev.cli.env.reload import reload_command
from ddev.cli.env.shell import shell
from ddev.cli.env.show import show
Expand All @@ -24,6 +25,7 @@ def env():
env.add_command(agent)
env.add_command(check)
env.add_command(config)
env.add_command(logs)
env.add_command(reload_command)
env.add_command(shell)
env.add_command(show)
Expand Down
20 changes: 16 additions & 4 deletions ddev/src/ddev/cli/env/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ def _validate_env_vars(ctx: click.Context, param: click.Parameter, value: tuple[
return env_vars or None


def _sync_restored_config(app: Application, agent: AgentInterface) -> None:
import sys

original_error = sys.exception()
try:
agent.sync_config()
except Exception as e:
if original_error is None:
raise
app.display_warning(f'Unable to restore the Agent configuration: {e}')


@click.command(
short_help='Invoke the Agent', context_settings={'help_option_names': [], 'ignore_unknown_options': True}
)
Expand Down Expand Up @@ -79,9 +91,8 @@ def agent(
"""
import subprocess

from ddev.e2e.agent import get_agent_interface
from ddev.e2e.agent import create_agent_interface
from ddev.e2e.config import EnvDataStorage
from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata
from ddev.utils.fs import Path

integration = app.repo.integrations.get(intg_name)
Expand All @@ -91,8 +102,7 @@ def agent(
app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running')

metadata = env_data.read_metadata()
agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)
agent = get_agent_interface(agent_type)(app, integration, environment, metadata, env_data.config_file)
agent = create_agent_interface(app, integration, environment, metadata, env_data.config_file)

full_args = list(args)
trigger_run = False
Expand Down Expand Up @@ -131,6 +141,7 @@ def agent(
app.abort(str(e))
finally:
env_data.config_file.unlink()
_sync_restored_config(app, agent)
else:
temp_config_file = env_data.config_file.parent / f'{env_data.config_file.name}.bak.example'
env_data.config_file.replace(temp_config_file)
Expand All @@ -141,3 +152,4 @@ def agent(
app.abort(str(e))
finally:
temp_config_file.replace(env_data.config_file)
_sync_restored_config(app, agent)
35 changes: 35 additions & 0 deletions ddev/src/ddev/cli/env/logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

from typing import TYPE_CHECKING

import click

if TYPE_CHECKING:
from ddev.cli.application import Application


@click.command('logs', short_help='Show logs for the Agent')
@click.argument('intg_name', metavar='INTEGRATION')
@click.argument('environment')
@click.pass_obj
def logs(app: Application, *, intg_name: str, environment: str):
"""Show backend-specific diagnostics for the Agent."""
from ddev.e2e.agent import create_agent_interface
from ddev.e2e.config import EnvDataStorage

integration = app.repo.integrations.get(intg_name)
env_data = EnvDataStorage(app.data_dir).get(integration.name, environment)

if not env_data.exists():
app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running')

metadata = env_data.read_metadata()
agent = create_agent_interface(app, integration, environment, metadata, env_data.config_file)

try:
agent.show_logs()
except Exception as e:
app.abort(str(e))
6 changes: 2 additions & 4 deletions ddev/src/ddev/cli/env/reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ def reload_command(app: Application, *, intg_name: str, environment: str):
"""
Restart the Agent to detect environment changes.
"""
from ddev.e2e.agent import get_agent_interface
from ddev.e2e.agent import create_agent_interface
from ddev.e2e.config import EnvDataStorage
from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata

integration = app.repo.integrations.get(intg_name)
env_data = EnvDataStorage(app.data_dir).get(integration.name, environment)
Expand All @@ -30,8 +29,7 @@ def reload_command(app: Application, *, intg_name: str, environment: str):
app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running')

metadata = env_data.read_metadata()
agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)
agent = get_agent_interface(agent_type)(app, integration, environment, metadata, env_data.config_file)
agent = create_agent_interface(app, integration, environment, metadata, env_data.config_file)

try:
agent.restart()
Expand Down
6 changes: 2 additions & 4 deletions ddev/src/ddev/cli/env/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ def shell(app: Application, *, intg_name: str, environment: str):
"""
import subprocess

from ddev.e2e.agent import get_agent_interface
from ddev.e2e.agent import create_agent_interface
from ddev.e2e.config import EnvDataStorage
from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata

integration = app.repo.integrations.get(intg_name)
env_data = EnvDataStorage(app.data_dir).get(integration.name, environment)
Expand All @@ -32,8 +31,7 @@ def shell(app: Application, *, intg_name: str, environment: str):
app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running')

metadata = env_data.read_metadata()
agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)
agent = get_agent_interface(agent_type)(app, integration, environment, metadata, env_data.config_file)
agent = create_agent_interface(app, integration, environment, metadata, env_data.config_file)

try:
agent.enter_shell()
Expand Down
4 changes: 2 additions & 2 deletions ddev/src/ddev/cli/env/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def show(app: Application, *, intg_name: str | None, environment: str | None, fo
app.display_table('Available', available_columns, show_lines=True, force_ascii=force_ascii)
# Display information about a specific environment
else:
from ddev.e2e.agent import get_agent_interface
from ddev.e2e.agent import create_agent_interface

integration = app.repo.integrations.get(intg_name)
env_data = storage.get(integration.name, environment)
Expand All @@ -82,7 +82,7 @@ def show(app: Application, *, intg_name: str | None, environment: str | None, fo

metadata = env_data.read_metadata()
agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)
agent = get_agent_interface(agent_type)(app, integration, environment, metadata, env_data.config_file)
agent = create_agent_interface(app, integration, environment, metadata, env_data.config_file)

app.display_pair('Agent type', agent_type)
app.display_pair('Agent ID', agent.get_id())
15 changes: 9 additions & 6 deletions ddev/src/ddev/cli/env/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def start(
result = json.loads(result_file.read_text())

metadata = result['metadata']
agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)

# TODO Remove once we have migrated the `docker_run` function
if serialized_volumes := metadata.get(E2EMetadata.ENV_VARS, {}).get(E2EEnvVars.DOCKER_VOLUMES):
Expand All @@ -144,24 +145,26 @@ def start(
config = result['config']
env_data.write_config(config)

agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)

if agent_type == "vagrant" and running_on_ci():
app.abort(text="Vagrant is not supported on CI", code=0)
agent_class = get_agent_interface(agent_type)
if running_on_ci() and not agent_class.supports_ci:
app.abort(text=f'{agent_type.capitalize()} is not supported on CI', code=0)

agent = get_agent_interface(agent_type)(app, integration, environment, metadata, env_data.config_file)
agent = agent_class(app, integration, environment, metadata, env_data.config_file)

if not agent_build:
configured_agent_build = agent.get_configured_build(app.config.agent.config)
agent_build = (
os.getenv(E2EEnvVars.AGENT_BUILD_PY2 if agent.python_version[0] == 2 else E2EEnvVars.AGENT_BUILD)
or app.config.agent.config.get(agent_type)
or configured_agent_build
or ''
)

agent_env_vars = _get_agent_env_vars(app.config.org.config, metadata, extra_env_vars, dogstatsd)

try:
agent.start(agent_build=agent_build, local_packages=local_packages, env_vars=agent_env_vars)
# Backends may add runtime metadata needed by later ddev processes.
env_data.write_metadata(metadata)
except Exception as e:
from ddev.cli.env.stop import stop

Expand Down
7 changes: 3 additions & 4 deletions ddev/src/ddev/cli/env/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ def stop(app: Application, *, intg_name: str, environment: str, ignore_state: bo
"""
Stop environments. To stop all the running environments, use `all` as the integration name and the environment.
"""
from ddev.e2e.agent import get_agent_interface
from ddev.e2e.agent import create_agent_interface
from ddev.e2e.config import EnvDataStorage
from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars, E2EMetadata
from ddev.e2e.constants import E2EEnvVars, E2EMetadata
from ddev.e2e.run import E2EEnvironmentRunner
from ddev.utils.fs import temp_directory

Expand Down Expand Up @@ -63,8 +63,7 @@ def stop(app: Application, *, intg_name: str, environment: str, ignore_state: bo
metadata = env_data.read_metadata()
env_vars.update(metadata.get(E2EMetadata.ENV_VARS, {}))

agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)
agent = get_agent_interface(agent_type)(app, integration, env, metadata, env_data.config_file)
agent = create_agent_interface(app, integration, env, metadata, env_data.config_file)

try:
agent.stop()
Expand Down
23 changes: 22 additions & 1 deletion ddev/src/ddev/e2e/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,26 @@
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from ddev.cli.application import Application
from ddev.e2e.agent.interface import AgentInterface
from ddev.integration.core import Integration
from ddev.utils.fs import Path


def create_agent_interface(
app: Application,
integration: Integration,
environment: str,
metadata: dict[str, Any],
config_file: Path,
) -> AgentInterface:
from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata

agent_type = metadata.get(E2EMetadata.AGENT_TYPE, DEFAULT_AGENT_TYPE)
return get_agent_interface(agent_type)(app, integration, environment, metadata, config_file)


def get_agent_interface(agent_type: str) -> type[AgentInterface]:
Expand All @@ -20,4 +36,9 @@ def get_agent_interface(agent_type: str) -> type[AgentInterface]:

return VagrantAgent

if agent_type == "kubernetes":
from ddev.e2e.agent.kubernetes import KubernetesAgent

return KubernetesAgent

raise NotImplementedError(f"Unsupported Agent type: {agent_type}")
Loading
Loading