Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Share running applications with clients by exposing ports publicly. Use `public_
- **Public Port Exposure**: Open ports directly for external access - perfect for client demos.
- **Ansible Integration**: Supports running Ansible playbooks to configure the instance on startup.
- **Multi-User Support**: Teams sharing an AWS account get automatic instance isolation. Each instance is tagged with the owner's identity, and `campers list` shows only your instances by default.
- **Docker-like Exec**: Run commands on running instances with `campers exec dev "command" -it` - no re-sync or re-provision needed.
- **Cost Control:** Encourages an ephemeral workflow where instances are destroyed when not in use.
- **TUI Dashboard**: A terminal interface to monitor logs, sync status, and instance health.

Expand Down Expand Up @@ -155,6 +156,12 @@ campers run training
# Start a client demo (share the public IP with clients)
campers run demo

# Open another shell to a running camp (like docker exec)
campers exec dev "/bin/bash" -it

# Run a one-off command without interrupting your session
campers exec dev "tail -f /var/log/app.log"

# Check status of all your camps (showing estimated monthly costs)
campers list
# ┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
Expand Down
197 changes: 195 additions & 2 deletions campers/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@
from campers.lifecycle import LifecycleManager
from campers.providers import get_provider # noqa: E402
from campers.services.portforward import PortForwardManager # noqa: E402
from campers.services.ssh import SSHManager # noqa: E402
from campers.services.ssh import ( # noqa: E402
SSHConnectionInfo,
SSHManager,
get_ssh_connection_info,
)
from campers.services.sync import MutagenManager # noqa: E402
from campers.session import SessionManager # noqa: E402
from campers.templates import CONFIG_TEMPLATE # noqa: E402
from campers.tui import CampersTUI # noqa: E402
from campers.utils import truncate_name # noqa: E402
from campers.utils import get_user_identity, truncate_name # noqa: E402


class Campers:
Expand Down Expand Up @@ -460,6 +465,194 @@ def destroy(self, name_or_id: str, region: str | None = None) -> None:
"""Destroy a managed instance."""
return self._lifecycle_manager_prop.destroy(name_or_id=name_or_id, region=region)

def exec(
self,
camp_or_instance: str,
command: str,
region: str | None = None,
i: bool = False,
t: bool = False,
it: bool = False,
interactive: bool = False,
tty: bool = False,
) -> int:
"""Execute a command on a running instance.

Parameters
----------
camp_or_instance : str
Camp name or instance ID to execute on
command : str
Command to execute on the remote instance
region : str | None
Optional region to narrow AWS discovery scope
i : bool
Short flag for interactive mode (keep stdin open)
t : bool
Short flag for TTY allocation
it : bool
Combined short flag for interactive mode with TTY (like docker exec -it)
interactive : bool
Long flag for interactive mode (keep stdin open)
tty : bool
Long flag for TTY allocation

Returns
-------
int
Exit code from the remote command

Raises
------
SystemExit
Exits with code 1 if instance not found, multiple instances found,
instance is not in running state, or TTY requirements not met
"""
use_interactive = i or it or interactive
use_tty = t or it or tty

if use_interactive and not sys.stdin.isatty():
logging.error(
"Cannot use interactive mode: stdin is not a terminal",
extra={"stream": "stderr"},
)
sys.exit(1)

if use_tty and not sys.stdout.isatty():
logging.error(
"Cannot allocate TTY: stdout is not a terminal",
extra={"stream": "stderr"},
)
sys.exit(1)

if use_interactive and not use_tty:
logging.warning(
"Using -i without -t has no effect; use -it for interactive mode",
extra={"stream": "stderr"},
)

default_region = self._config_loader.BUILT_IN_DEFAULTS["region"]

if region is not None:
self._validate_region(region)

session_manager = SessionManager()

session = session_manager.get_alive_session(camp_or_instance)

if session:
ssh_info = SSHConnectionInfo(
host=session.ssh_host,
port=session.ssh_port,
key_file=session.key_file,
username=session.ssh_user,
)
else:
instance = self._discover_running_instance(camp_or_instance, region, default_region)
ssh_info = get_ssh_connection_info(
instance["instance_id"],
instance["public_ip"],
instance["key_file"],
)

ssh_manager = self._ssh_manager_factory(
host=ssh_info.host,
key_file=ssh_info.key_file,
port=ssh_info.port,
username=ssh_info.username,
)
ssh_manager.connect()

try:
if use_tty:
return ssh_manager.execute_interactive(command)
else:
return ssh_manager.execute_command(command)
finally:
ssh_manager.close()

def _discover_running_instance(
self, camp_or_instance: str, region: str | None, default_region: str
) -> dict[str, Any]:
"""Discover a running instance by name or ID.

Parameters
----------
camp_or_instance : str
Camp name or instance ID
region : str | None
Optional region to narrow search
default_region : str
Default region to use if not specified

Returns
-------
dict[str, Any]
Instance details if found and unique

Raises
------
SystemExit
Exits with code 1 if no instance found, multiple found, or not running
"""
compute_provider = self._compute_provider_factory(region=region or default_region)
matches = compute_provider.find_instances_by_name_or_id(
name_or_id=camp_or_instance, region_filter=region
)

if not matches:
logging.error(
"No running instance found for '%s'. Use 'campers run' to start one.",
camp_or_instance,
extra={"stream": "stderr"},
)
sys.exit(1)

if len(matches) > 1:
logging.error(
"Multiple instances found for '%s':",
camp_or_instance,
extra={"stream": "stderr"},
)
for match in matches:
logging.error(
f" {match['instance_id']} ({match['region']}) "
f"- {match.get('state', 'unknown')}",
extra={"stream": "stderr"},
)
logging.error(
"Specify instance ID: campers exec %s <command>",
matches[0]["instance_id"],
extra={"stream": "stderr"},
)
sys.exit(1)

instance = matches[0]

if not camp_or_instance.startswith("i-"):
current_user = get_user_identity()
if instance.get("owner") != current_user:
logging.error(
"Instance '%s' is owned by '%s', not by you ('%s'). "
"Use instance ID to access instances from other users.",
camp_or_instance,
instance.get("owner"),
current_user,
extra={"stream": "stderr"},
)
sys.exit(1)

if instance.get("state") != "running":
logging.error(
"Instance '%s' is %s. Use 'campers start' first.",
camp_or_instance,
instance.get("state", "unknown"),
extra={"stream": "stderr"},
)
sys.exit(1)

return instance

def setup(self, region: str | None = None) -> None:
"""Set up cloud environment and validate configuration."""
return self._setup_manager_prop.setup(region=region)
Expand Down
30 changes: 30 additions & 0 deletions campers/core/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,33 @@ def cleanup_ssh_connections(self, resources: dict[str, Any], errors: list[Except

self._emit_cleanup_event("close_ssh", "failed")

def cleanup_session_file(self, resources: dict[str, Any], errors: list[Exception]) -> None:
"""Delete session file when campers run exits.

Parameters
----------
resources : dict[str, Any]
Resources dictionary containing session_manager and session_camp_name
errors : list[Exception]
List to accumulate errors during cleanup

Notes
-----
Errors are logged and added to errors list but do not halt cleanup.
"""
if "session_manager" not in resources or "session_camp_name" not in resources:
logging.debug("Skipping session file cleanup - not initialized")
return

logging.debug("Deleting session file...")

try:
resources["session_manager"].delete_session(resources["session_camp_name"])
logging.debug("Session file deleted successfully")
except (OSError, ValueError) as e:
logging.error("Error deleting session file: %s", e)
errors.append(e)

def cleanup_port_forwarding(self, resources: dict[str, Any], errors: list[Exception]) -> None:
"""Stop SSH port forwarding tunnels.

Expand Down Expand Up @@ -446,6 +473,7 @@ def stop_instance_cleanup(self, signum: int | None = None) -> None:
self.cleanup_port_forwarding(resources_to_clean, errors)
self.cleanup_mutagen_session(resources_to_clean, errors)
self.cleanup_ssh_connections(resources_to_clean, errors)
self.cleanup_session_file(resources_to_clean, errors)

success, error_msg = self._cleanup_instance_helper(resources_to_clean, errors, "stop")
if not success and error_msg:
Expand Down Expand Up @@ -499,6 +527,7 @@ def terminate_instance_cleanup(self, signum: int | None = None) -> None:
self.cleanup_port_forwarding(resources_to_clean, errors)
self.cleanup_mutagen_session(resources_to_clean, errors)
self.cleanup_ssh_connections(resources_to_clean, errors)
self.cleanup_session_file(resources_to_clean, errors)

success, error_msg = self._cleanup_instance_helper(
resources_to_clean, errors, "terminate"
Expand Down Expand Up @@ -556,6 +585,7 @@ def detach_cleanup(self, signum: int | None = None) -> None:
self.cleanup_port_forwarding(resources_to_clean, errors)
self.cleanup_mutagen_session(resources_to_clean, errors)
self.cleanup_ssh_connections(resources_to_clean, errors)
self.cleanup_session_file(resources_to_clean, errors)

instance_details = resources_to_clean.get("instance_details", {})
instance_id = get_instance_id(instance_details)
Expand Down
17 changes: 17 additions & 0 deletions campers/core/run_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from campers.services.portforward import PortForwardManager, PortInUseError, is_port_in_use
from campers.services.ssh import SSHManager, get_ssh_connection_info
from campers.services.sync import MutagenManager
from campers.session import SessionInfo, SessionManager
from campers.utils import generate_instance_name, status_spinner

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -458,8 +459,24 @@ def _phase_ssh_connection(
update_queue, {"type": "status_update", "payload": {"status": "running"}}
)

session_manager = SessionManager()
session_info = SessionInfo(
camp_name=merged_config["camp_name"],
pid=os.getpid(),
instance_id=instance_details["instance_id"],
region=merged_config["region"],
ssh_host=ssh_info.host,
ssh_port=ssh_info.port,
ssh_user=ssh_username,
key_file=ssh_info.key_file,
)
session_manager.create_session(session_info)
logging.debug("Session file created for %s", merged_config["camp_name"])

with self.resources_lock:
self.resources["ssh_manager"] = ssh_manager
self.resources["session_manager"] = session_manager
self.resources["session_camp_name"] = merged_config["camp_name"]

return ssh_manager, ssh_info.host, ssh_info.port

Expand Down
Loading