| 1 | #!/usr/bin/env python3
 | 
| 2 | """
 | 
| 3 | headless_demo.py
 | 
| 4 | 
 | 
| 5 | We're using Python 3 because it supports descriptor passing.
 | 
| 6 | 
 | 
| 7 | Steps:
 | 
| 8 | 
 | 
| 9 | - Create socketpair() for communication between 2 processes
 | 
| 10 | - fork() and exec() osh --headless.
 | 
| 11 | - Communicate synchronously
 | 
| 12 | """
 | 
| 13 | import optparse
 | 
| 14 | import os
 | 
| 15 | import pty
 | 
| 16 | import socket
 | 
| 17 | import sys
 | 
| 18 | 
 | 
| 19 | import py_fanos
 | 
| 20 | from py_fanos import log
 | 
| 21 | 
 | 
| 22 | 
 | 
| 23 | # EVAL x
 | 
| 24 | COMMANDS = [
 | 
| 25 |   b'echo hi',          # OK, and prints 'hi' to stdout file descriptor
 | 
| 26 |   b'echo !!',          # no history
 | 
| 27 | 
 | 
| 28 |   # Headless mode uses something like 'eval' to handle multiline commands
 | 
| 29 |   b'echo one\necho two\necho three\n',  # multiline
 | 
| 30 |   b'echo 1;\necho 2;\necho 3\n',        # with semicolons
 | 
| 31 | 
 | 
| 32 |   b'( \necho subshell\n)\n',            # proper multiline command
 | 
| 33 | 
 | 
| 34 |   b'ls --color=auto',  # OK, and make sure it's in color!
 | 
| 35 |   b'read x',           # OK, and x is assigned
 | 
| 36 |   b'echo "x: $x"',     # OK, we maintained state
 | 
| 37 |   b'(',                # OK, syntax error to stderr  
 | 
| 38 |   b'zzZZ',             # OK, and runtime error to stderr
 | 
| 39 |   b'declare -X',       # OK, and runtime error to stderr
 | 
| 40 |   b'echo PS1=${PS1@P}',   # typical prompt command
 | 
| 41 |   b'echo $? $PWD',    # dump state.  TODO: JSON?
 | 
| 42 | 
 | 
| 43 |   # Errors at top level
 | 
| 44 |   b'break',    
 | 
| 45 |   b'continue',    
 | 
| 46 | 
 | 
| 47 |   # Hm this could actually return
 | 
| 48 |   b'return',    
 | 
| 49 |   b'exit',   # This exists the process
 | 
| 50 |   b'echo done',    
 | 
| 51 | 
 | 
| 52 |   # What about async commands like &
 | 
| 53 |   # I think that works the same?
 | 
| 54 | 
 | 
| 55 |   # Is this valid?  EVAL space?  I think it probably shouldn't be?
 | 
| 56 |   b'',
 | 
| 57 |   # What about invalid netstrings?
 | 
| 58 | ]
 | 
| 59 | 
 | 
| 60 | 
 | 
| 61 | def ShowDescriptorState(label):
 | 
| 62 |   if 1:
 | 
| 63 |     pid = os.getpid()
 | 
| 64 |     print(label + ' (PID %d)' % pid, file=sys.stderr)
 | 
| 65 |     os.system('ls -l /proc/%d/fd >&2' % pid)
 | 
| 66 | 
 | 
| 67 | 
 | 
| 68 | def main(argv):
 | 
| 69 |   p = optparse.OptionParser(__doc__)
 | 
| 70 | 
 | 
| 71 |   # By default, the server will use the stdin/stdout/stderr of THIS CLIENT
 | 
| 72 |   # PROCESS.
 | 
| 73 |   p.add_option(
 | 
| 74 |       '--stdin-file', dest='stdin_file', default='/dev/stdin',
 | 
| 75 |       help='Where the server read stdin from')
 | 
| 76 |   p.add_option(
 | 
| 77 |       '--stdout-file', dest='stdout_file', default='/dev/stdout',
 | 
| 78 |       help='Where the server should send child stdout')
 | 
| 79 |   p.add_option(
 | 
| 80 |       '--stderr-file', dest='stderr_file', default='/dev/stderr',
 | 
| 81 |       help='Where the server should send child stdout')
 | 
| 82 |   p.add_option(
 | 
| 83 |       '--sh-binary', dest='sh_binary', default='bin/osh',
 | 
| 84 |       help='Which shell binary to launch')
 | 
| 85 | 
 | 
| 86 |   # Use a terminal instead
 | 
| 87 |   p.add_option(
 | 
| 88 |       '--to-new-pty', dest='to_new_pty', default=False, action='store_true',
 | 
| 89 |       help='Send the child stdout to a new PTY')
 | 
| 90 | 
 | 
| 91 |   opts, args = p.parse_args(argv[1:])
 | 
| 92 | 
 | 
| 93 |   # left: we read and write from it
 | 
| 94 |   # right: the server we spawn reads and writes.
 | 
| 95 |   left, right = socket.socketpair()
 | 
| 96 | 
 | 
| 97 |   ShowDescriptorState('parent/client BEFORE')
 | 
| 98 | 
 | 
| 99 |   # The server/child should inherit these descriptors
 | 
| 100 |   os.set_inheritable(left.fileno(), True)
 | 
| 101 |   os.set_inheritable(right.fileno(), True)
 | 
| 102 | 
 | 
| 103 |   child_argv = [opts.sh_binary, '--headless']
 | 
| 104 | 
 | 
| 105 |   ret = os.fork()
 | 
| 106 |   if ret < 0:
 | 
| 107 |     raise AssertionError('fork failed')
 | 
| 108 | 
 | 
| 109 |   elif ret == 0:
 | 
| 110 |     left.close()  # close parent end in child
 | 
| 111 | 
 | 
| 112 |     # osh --headless will read from stdin, and write to stdout, which are both
 | 
| 113 |     # the RIGHT socket.
 | 
| 114 |     os.dup2(right.fileno(), 0)
 | 
| 115 |     os.dup2(right.fileno(), 1)
 | 
| 116 |     right.close()  # we don't need this either
 | 
| 117 | 
 | 
| 118 |     import time
 | 
| 119 |     time.sleep(0.1)  # prevent interleaving of parent/child state
 | 
| 120 |     ShowDescriptorState('child/server')
 | 
| 121 | 
 | 
| 122 |     # never returns
 | 
| 123 |     os.execv(child_argv[0], child_argv)
 | 
| 124 |   else:
 | 
| 125 |     right.close()  # close child end in parent
 | 
| 126 | 
 | 
| 127 |     ShowDescriptorState('parent/client AFTER')
 | 
| 128 | 
 | 
| 129 |   master_fd, slave_fd = -1, -1
 | 
| 130 | 
 | 
| 131 |   if opts.to_new_pty:
 | 
| 132 |     master_fd, slave_fd = os.openpty()
 | 
| 133 | 
 | 
| 134 |     stdin_fd = slave_fd
 | 
| 135 |     stdout_fd = slave_fd
 | 
| 136 |     stderr_fd = slave_fd
 | 
| 137 | 
 | 
| 138 |     log('master %d slave %d', master_fd, slave_fd)
 | 
| 139 |     #os.close(slave_fd)
 | 
| 140 | 
 | 
| 141 |   else:
 | 
| 142 |     stdin_fd = os.open(opts.stdin_file, 0)
 | 
| 143 |     stdout_fd = os.open(opts.stdout_file, os.O_RDWR | os.O_CREAT)
 | 
| 144 |     stderr_fd = os.open(opts.stderr_file, os.O_RDWR | os.O_CREAT)
 | 
| 145 | 
 | 
| 146 |   log('stdout_fd = %d', stdout_fd)
 | 
| 147 | 
 | 
| 148 |   # Send raw requests, for testing protocol errors
 | 
| 149 |   if args:
 | 
| 150 |     raw_requests = [a.encode('utf-8') for a in args]
 | 
| 151 | 
 | 
| 152 |     status = 0
 | 
| 153 |     for req in raw_requests:
 | 
| 154 |       left.send(req)
 | 
| 155 | 
 | 
| 156 |       try:
 | 
| 157 |         reply = py_fanos.recv(left)
 | 
| 158 |       except ValueError as e:
 | 
| 159 |         log('FANOS protocol error: %s', e)
 | 
| 160 |         break
 | 
| 161 | 
 | 
| 162 |       log('reply %r' % reply)
 | 
| 163 | 
 | 
| 164 |       if reply.startswith(b'ERROR'):
 | 
| 165 |         status = 1
 | 
| 166 | 
 | 
| 167 | 
 | 
| 168 |       if reply is None:
 | 
| 169 |         break
 | 
| 170 |     return status
 | 
| 171 | 
 | 
| 172 |   # The normal path
 | 
| 173 | 
 | 
| 174 |   commands = [b'GETPID']
 | 
| 175 |   #commands = [b'EVAL echo prompt ${PS1@P}']
 | 
| 176 |   commands.extend(b'EVAL ' + c for c in COMMANDS)
 | 
| 177 | 
 | 
| 178 |   for cmd in commands:
 | 
| 179 |     py_fanos.send(left, cmd, [stdin_fd, stdout_fd, stderr_fd])
 | 
| 180 | 
 | 
| 181 |     try:
 | 
| 182 |       reply = py_fanos.recv(left)
 | 
| 183 |     except ValueError as e:
 | 
| 184 |       log('FANOS protocol error: %s', e)
 | 
| 185 |       break
 | 
| 186 | 
 | 
| 187 |     log('reply %r' % reply)
 | 
| 188 |     if reply is None:
 | 
| 189 |       break
 | 
| 190 | 
 | 
| 191 |   left.close()
 | 
| 192 | 
 | 
| 193 |   if master_fd != -1:
 | 
| 194 |     # This hangs because the server still has the terminal open?  Not sure
 | 
| 195 |     # where to close it.
 | 
| 196 |     while True:
 | 
| 197 |       chunk = os.read(master_fd, 1024)
 | 
| 198 |       if not chunk:
 | 
| 199 |         break
 | 
| 200 |       log('from pty: %r', chunk)
 | 
| 201 | 
 | 
| 202 |   return 0
 | 
| 203 | 
 | 
| 204 | 
 | 
| 205 | if __name__ == '__main__':
 | 
| 206 |   try:
 | 
| 207 |     sys.exit(main(sys.argv))
 | 
| 208 |   except RuntimeError as e:
 | 
| 209 |     print('FATAL: %s' % e, file=sys.stderr)
 | 
| 210 |     sys.exit(1)
 |