Skip to content

Commit f2f1906

Browse files
committed
Add console support
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 293bf14 commit f2f1906

File tree

5 files changed

+284
-1
lines changed

5 files changed

+284
-1
lines changed

src/kerf/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .unload.main import unload
2727
from .delete.main import delete
2828
from .show.main import show
29+
from .console.main import console
2930

3031

3132
@click.group()
@@ -48,6 +49,7 @@ def main(ctx, debug):
4849
main.add_command(unload)
4950
main.add_command(delete)
5051
main.add_command(show)
52+
main.add_command(console)
5153

5254

5355
if __name__ == "__main__":

src/kerf/console/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2025 Multikernel Technologies, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Console attachment subcommand implementation.
17+
"""
18+
19+
from .main import console, run_console
20+
21+
__all__ = ["console", "run_console"]

src/kerf/console/main.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# Copyright 2025 Multikernel Technologies, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Console attachment subcommand implementation for mktty device.
17+
"""
18+
19+
import os
20+
import select
21+
import sys
22+
import termios
23+
import tty
24+
from pathlib import Path
25+
from typing import Optional
26+
27+
import click
28+
29+
from ..models import InstanceState
30+
from ..utils import get_instance_id_from_name, get_instance_name_from_id, get_instance_status
31+
32+
33+
MKTTY_DEVICE = "/dev/mktty"
34+
CTRL_CLOSE_BRACKET = 0x1D # Ctrl+]
35+
36+
37+
def run_console(instance_id: int, instance_name: str, verbose: bool = False) -> int:
38+
"""
39+
Attach to a running instance's console via mktty device.
40+
41+
Args:
42+
instance_id: The instance ID to attach to
43+
instance_name: The instance name (for display purposes)
44+
verbose: Enable verbose output
45+
46+
Returns:
47+
0 on success, non-zero on error
48+
"""
49+
# Check if mktty device exists
50+
if not Path(MKTTY_DEVICE).exists():
51+
click.echo(f"Error: Console device {MKTTY_DEVICE} not found", err=True)
52+
click.echo("Make sure the mktty kernel module is loaded", err=True)
53+
return 1
54+
55+
# Check if stdin is a tty
56+
if not sys.stdin.isatty():
57+
click.echo("Error: stdin is not a terminal", err=True)
58+
return 1
59+
60+
click.echo(f"Connecting to console for instance '{instance_name}' (ID: {instance_id})...")
61+
click.echo("Escape sequence: Ctrl+] followed by . to detach")
62+
click.echo("")
63+
64+
# Open mktty device
65+
try:
66+
mktty_fd = os.open(MKTTY_DEVICE, os.O_RDWR)
67+
except OSError as e:
68+
click.echo(f"Error: Failed to open {MKTTY_DEVICE}: {e}", err=True)
69+
if e.errno == 13: # EACCES
70+
click.echo("Note: This operation may require root privileges", err=True)
71+
return 1
72+
73+
try:
74+
# Write instance ID to mktty device to initiate connection
75+
instance_id_str = f"{instance_id}\n"
76+
os.write(mktty_fd, instance_id_str.encode("utf-8"))
77+
78+
# Save terminal settings
79+
stdin_fd = sys.stdin.fileno()
80+
stdout_fd = sys.stdout.fileno()
81+
old_settings = termios.tcgetattr(stdin_fd)
82+
83+
try:
84+
# Enter raw mode
85+
tty.setraw(stdin_fd)
86+
87+
# State for detach sequence detection
88+
saw_ctrl_bracket = False
89+
90+
# I/O loop
91+
while True:
92+
readable, _, _ = select.select([stdin_fd, mktty_fd], [], [], 0.1)
93+
94+
for fd in readable:
95+
if fd == stdin_fd:
96+
# Read from stdin
97+
data = os.read(stdin_fd, 1)
98+
if not data:
99+
# EOF on stdin
100+
return 0
101+
102+
byte = data[0]
103+
104+
# Check for detach sequence: Ctrl+] followed by .
105+
if saw_ctrl_bracket:
106+
if byte == ord('.'):
107+
# Detach sequence complete
108+
return 0
109+
else:
110+
# Not a detach sequence, send the buffered Ctrl+]
111+
os.write(mktty_fd, bytes([CTRL_CLOSE_BRACKET]))
112+
saw_ctrl_bracket = False
113+
# Fall through to send current byte
114+
115+
if byte == CTRL_CLOSE_BRACKET:
116+
# Start of potential detach sequence
117+
saw_ctrl_bracket = True
118+
else:
119+
# Send to mktty device
120+
os.write(mktty_fd, data)
121+
122+
elif fd == mktty_fd:
123+
# Read from mktty device
124+
try:
125+
data = os.read(mktty_fd, 4096)
126+
if data:
127+
# Translate \n to \r\n for proper terminal display
128+
# in raw mode (kernel outputs \n, terminal needs \r\n)
129+
# First normalize any existing \r\n to \n, then convert
130+
data = data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n')
131+
os.write(stdout_fd, data)
132+
except OSError:
133+
# Device closed or error
134+
return 0
135+
136+
finally:
137+
# Restore terminal settings
138+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
139+
click.echo("")
140+
click.echo("Disconnected from console.")
141+
142+
finally:
143+
os.close(mktty_fd)
144+
145+
return 0
146+
147+
148+
@click.command(name="console")
149+
@click.argument("name", required=False)
150+
@click.option("--id", type=int, help="Instance ID (alternative to name)")
151+
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
152+
def console(name: Optional[str], id: Optional[int], verbose: bool):
153+
"""
154+
Attach to a running instance's console.
155+
156+
Connect to the console of a running multikernel instance via the mktty
157+
device. All input including Ctrl+C is passed through to the spawn kernel.
158+
159+
To detach from the console, press Ctrl+] followed by . (period).
160+
161+
Examples:
162+
163+
kerf console web-server
164+
kerf console --id=1
165+
"""
166+
try:
167+
if not name and id is None:
168+
click.echo("Error: Either instance name or --id must be provided", err=True)
169+
click.echo("Usage: kerf console <name> or kerf console --id=<id>", err=True)
170+
sys.exit(2)
171+
172+
instance_name = None
173+
instance_id = None
174+
175+
if name:
176+
# Use name, convert to ID
177+
instance_name = name
178+
instance_id = get_instance_id_from_name(name)
179+
180+
if instance_id is None:
181+
click.echo(f"Error: Instance '{name}' not found", err=True)
182+
click.echo("Check available instances in /sys/fs/multikernel/instances/", err=True)
183+
sys.exit(1)
184+
185+
if verbose:
186+
click.echo(f"Instance name: {name} (ID: {instance_id})")
187+
else:
188+
# Use ID directly, need to find name
189+
instance_id = id
190+
191+
if instance_id < 1 or instance_id > 511:
192+
click.echo(f"Error: --id must be between 1 and 511 (got {instance_id})", err=True)
193+
sys.exit(2)
194+
195+
instance_name = get_instance_name_from_id(instance_id)
196+
if not instance_name:
197+
click.echo(f"Error: Instance with ID {instance_id} not found", err=True)
198+
click.echo("Check available instances in /sys/fs/multikernel/instances/", err=True)
199+
sys.exit(1)
200+
201+
if verbose:
202+
click.echo(f"Instance name: {instance_name} (ID: {instance_id})")
203+
204+
# Check instance status - must be active
205+
status = get_instance_status(instance_name)
206+
if status is None:
207+
click.echo(f"Error: Failed to read status for instance '{instance_name}'", err=True)
208+
sys.exit(1)
209+
210+
if status.lower() != InstanceState.ACTIVE.value:
211+
click.echo(
212+
f"Error: Instance '{instance_name}' is not active (status: '{status}')",
213+
err=True,
214+
)
215+
click.echo(
216+
f"Console attachment requires the instance to be in '{InstanceState.ACTIVE.value}' state.",
217+
err=True,
218+
)
219+
click.echo(f"Start the instance with: kerf exec {instance_name}", err=True)
220+
sys.exit(1)
221+
222+
if verbose:
223+
click.echo(f"Instance status: {status}")
224+
225+
# Run the console
226+
result = run_console(instance_id, instance_name, verbose)
227+
sys.exit(result)
228+
229+
except Exception as e:
230+
click.echo(f"Unexpected error: {e}", err=True)
231+
if verbose:
232+
import traceback
233+
234+
traceback.print_exc()
235+
sys.exit(1)

src/kerf/exec/main.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,23 @@ def boot_multikernel(mk_id: int) -> int:
103103
@click.command(name="exec")
104104
@click.argument("name", required=False)
105105
@click.option("--id", type=int, help="Multikernel instance ID to boot (alternative to name)")
106+
@click.option("--console", "attach_console", is_flag=True, help="Attach to console after boot")
106107
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
107-
def exec_cmd(name: Optional[str], id: Optional[int], verbose: bool):
108+
def exec_cmd(name: Optional[str], id: Optional[int], attach_console: bool, verbose: bool):
108109
"""
109110
Boot a multikernel instance using the reboot syscall.
110111
111112
This command boots a previously loaded multikernel instance by name or ID using
112113
the reboot syscall with the MULTIKERNEL command.
113114
115+
Use --console to immediately attach to the instance's console after boot.
116+
Press Ctrl+] followed by . to detach from the console.
117+
114118
Examples:
115119
116120
kerf exec web-server
117121
kerf exec --id=1
122+
kerf exec web-server --console
118123
"""
119124
try:
120125
if not name and id is None:
@@ -217,6 +222,14 @@ def exec_cmd(name: Optional[str], id: Optional[int], verbose: bool):
217222
else:
218223
click.echo("✓ Boot command executed successfully")
219224

225+
# Attach to console if requested
226+
if attach_console:
227+
from ..console import run_console
228+
229+
console_result = run_console(instance_id, instance_name, verbose)
230+
if console_result != 0:
231+
sys.exit(console_result)
232+
220233
# Note: If successful, this syscall will reboot the system and boot the
221234
# specified multikernel instance, so we may not reach this point.
222235

src/kerf/load/main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def kexec_file_load(
214214
@click.option("--netmask", default="255.255.255.0", help="Network mask (default: 255.255.255.0)")
215215
@click.option("--nic", help="Network interface name (e.g., eth0)")
216216
@click.option("--hostname", help="Hostname for spawn kernel")
217+
@click.option("--console", "console_device", help="Console device (e.g., mktty0)")
217218
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
218219
def load( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
219220
ctx: click.Context,
@@ -230,6 +231,7 @@ def load( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
230231
netmask: str,
231232
nic: Optional[str],
232233
hostname: Optional[str],
234+
console_device: Optional[str],
233235
verbose: bool,
234236
):
235237
"""
@@ -267,6 +269,10 @@ def load( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
267269
# Load with DHCP
268270
kerf load web-server --kernel=/boot/vmlinuz --image=nginx:latest \\
269271
--ip=dhcp --nic=eth0
272+
273+
# Load with console enabled
274+
kerf load web-server --kernel=/boot/vmlinuz --image=nginx:latest \\
275+
--console=mktty0
270276
"""
271277
try:
272278
if not name and id is None:
@@ -463,6 +469,12 @@ def load( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
463469
if verbose:
464470
click.echo(f"Network config: {ip_param}")
465471

472+
# Add console device if specified
473+
if console_device:
474+
cmdline_parts.append(f"console={console_device}")
475+
if verbose:
476+
click.echo(f"Console: console={console_device}")
477+
466478
cmdline_str = " ".join(cmdline_parts)
467479

468480
if verbose:

0 commit comments

Comments
 (0)