top of page

Interacting with server architecture through Python and Fabric

Zaktualizowano: 23 wrz 2024




Introduction:

A couple of years ago, I ran into a snag with an automated deployment process. We were heavily using Paramiko, the go-to Python SSH library for server upkeep. But after a server update, it started failing on certain sudo commands, spitting out security errors. No matter what I tried, I couldn't resolve the issue.


Then came Fabric, and boy, did it handle sudo commands like a dream! No more security hiccups like with Paramiko. Fabric's built-in sudo() function was a game-changer, making server updates a breeze without any extra fuss or glitches.


Why Transition from Paramiko to Fabric?


Switching from Paramiko to Fabric can be a game-changer. Fabric takes Paramiko's SSH protocol capabilities in Python and kicks it up a notch. It's like having a Swiss Army knife for SSH tasks—command execution, file transfers, you name it. Fabric's user-friendly approach means you don't have to wrestle with the complexities of SSH just to get things done.


Remember the headache when security updates messed with Paramiko's sudo command handling? Fabric swoops in as the hero, smoothing out those issues without making you an SSH guru overnight. It's all about making life easier. Paramiko gives you the tools to tailor your SSH interactions, but Fabric? It's like the friendly neighborhood barista who knows your order—it just gets the job done with no fuss.


And hey, if you're juggling commands across multiple servers or in parallel, Fabric's robust SSH session management is like having an extra pair of hands. It's that reliable buddy who's got your back when you're multitasking like a pro.


Fabric's Key Features

  • SSH Connections:

    No more manual server hopping! Fabric lets you connect to a bunch of servers and run commands with just a sprinkle of Python.

  • Running Commands:

    Need to run shell commands or scripts on a remote server? Fabric's got you covered. It's like having a remote control for your servers.

  • Handling Interactive Commands:

    Got a command that's asking for input? Fabric can deal with those pesky prompts for passwords or confirmations with its expect feature.

  • Sudo and Privilege Escalation:

    When you need to go superuser mode, Fabric can handle sudo commands, making sure you've got the power when you need it.

  • File Operations:

    Moving files between your local and remote servers is a breeze with Fabric's upload and download capabilities.

  • Parallel Execution:

    Updating multiple servers at once? Fabric makes it a walk in the park with its parallel task execution. Say goodbye to waiting around!

  • With ThreadingGroup, you can execute tasks across multiple servers in parallel, reducing the time spent on large-scale updates.


Example:

Handling Interactive Commands- Here’s an example of how you might use Fabric to handle an interactive command that requires user input:

```Python
from fabric import Connection

def handle_interactive_command():
	conn = Connection(host='your_server_ip', user='your_user', connect_kwargs={'password': 'your_password'}) 

# Example of running a command that requires user input 
result = conn.run(
	'some_interactive_command', pty=True, watchers=[ 	
	Responder(pattern=r'Enter your choice:', response='y\n'), 	
	Responder(pattern=r'Password:', response='your_password\n') ]
	)	 

print(result.stdout) 

if __name__ == "__main__":
    handle_interactive_command()
```
  • pty=True: This parameter ensures that the command runs in a pseudo-terminal, which is necessary for handling interactive prompts.

  • watchers: This parameter is a list of Responder objects that automatically respond to specific prompts.


The Responder class in Fabric is designed to identify patterns in command output and automate the appropriate responses. Essentially, it streamlines user input automation for unpredictable prompts, such as during package upgrades or firewall rule management.


For instance, when using sudo with Fabric, you can execute a command as follows:

```Python 
from fabric import Connection
from invoke import Responder

def handle_interactive_command():
    try:
        conn = Connection(host='your_server_ip', user='your_user', connect_kwargs={'password': 'your_password'})
        result = conn.run(
            'some_interactive_command', pty=True, watchers=[
                Responder(pattern=r'Enter your choice:', response='y\n'),
                Responder(pattern=r'Password:', response='your_password\n')
            ]
        )
        print(result.stdout)
    except Exception as e:
        print(f"Error handling command: {e}")

if __name__ == "__main__":
    handle_interactive_command()
```

In this example:

  • conn.sudo: This method runs the command with sudo privileges. password: This parameter is used to provide the sudo password.


Fabric's suite of features, including interactive command handling, SSH connection management, sudo command execution, and file operations, make it an indispensable tool for automating server management tasks. These capabilities enable the creation of powerful automation scripts that can manage complex tasks and streamline deployment processes.

At my previous job, I was deeply involved with server architecture, managing clusters, and automating updates. A critical task was to ensure that release candidates were uploaded and installed overnight, allowing the development team to receive testing results early in the morning.


While I didn't complete the final script version, my draft highlights Fabric's potential in automating cluster updates and managing server configurations with efficiency.


Here’s an example of using Fabric to automate a cluster update process, which includes saving configurations, stopping services, performing updates, and verifying the results across multiple servers.

Step-by-Step Implementation Step 1:

Set Up the Environment

Install Fabric:

```bash
 pip install fabric 
```

Create a Python Script:

```Python 
from fabric import Connection
import time

def read_version_file(conn, file_path):
    result = conn.run(f'cat {file_path}')
    return result.stdout.strip()

def check_group_exists(conn, group_name):
    result = conn.run(f'getent group {group_name}', warn=True)
    return result.ok

def check_user_exists(conn, user_name):
    result = conn.run(f'id -u {user_name}', warn=True)
    return result.ok

def check_user_in_group(conn, user_name, group_name):
    result = conn.run(f'groups {user_name}', warn=True)
    return group_name in result.stdout

def wait_for_server(conn, timeout=600):
    start_time = time.time()
    while time.time() - start_time < timeout:
        try:
            conn.run('uptime')
            return True
        except Exception:
            time.sleep(10)
    return False


def update_cluster():     
	servers = ['ip1', 'ip2', 'ip3']
     user = 'your_user'
     password = 'your_password'
    # Save existing configuration
 	try:
         saved_configs = {}
         for server in servers:
             conn = Connection(
				host=server, user=user, connect_kwargs={'password': password})
             saved_configs[server] = conn.run(
				'cat /etc/samba/smb.conf').stdout
     except Exception as e:
         print(f"Error saving configuration: {e}")
         return
      # Break the cluster
     try:
         for server in servers:
             conn = Connection(
			host=server, user=user, connect_kwargs={'password': password})
             conn.sudo('systemctl stop cluster_service')
     except Exception as e:
         print(f"Error stopping cluster service: {e}")
         return
      # Perform update
     for server in servers:
         try:
             conn = Connection(host=server, user=user, connect_kwargs={'password': password})
             version = read_version_file(
				conn, '/path/to/version_file.txt') # replace that with proper path
             print(f"Updating {server} to version: {version}")
             result = conn.sudo(f'sh /path/to/update_script.sh {version}', warn=True)
             if "ERROR" in result.stdout or "WARN" in result.stdout:
                print(f"Installation output on {server} contains warnings or errors:")
                 print(result.stdout)
         except Exception as e:
             print(f"Error during update on {server}:
 {e}")
 # Verify the installation
         result = conn.run('cat /path/to/installation_log.txt')
         print(result.stdout)          
# Verify configuration persistence
         result = conn.run('cat /etc/samba/smb.conf')
         print(result.stdout)
          # Check if specific groups exist
         if not check_group_exists(conn, 'your_group'):
             print(f"Group 'your_group' does not exist on {server}")
          # Check if regular user exists
         if not check_user_exists(conn, regular_user):
             print(f"User '{regular_user}' does not exist on {server}")
          # Check if regular user is in the proper group
         if not check_user_in_group(conn, regular_user, 'your_group'):
             print(f"User '{regular_user}' is not in group 'your_group' on {server}")
      # Restart the servers and wait for them to come back online
     for server in servers:
         conn = Connection(host=server, user=user, connect_kwargs={'password': password})
         conn.sudo('systemctl start cluster_service')
         if not wait_for_server(conn):
             print(f"Server {server} did not restart in time")
             return
      # Verify the configuration after restart
     for server in servers:
         conn = Connection(host=server, user=user, connect_kwargs={'password': password})
         current_config = conn.run('cat /etc/samba/smb.conf').stdout
         if current_config != saved_configs[server]:
             print(f"Configuration mismatch on {server}")
         else:
             print(f"Configuration on {server} matches the saved configuration")


if __name__ == "__main__":
     update_cluster()  
```

Step 2: Write Tests Using pytest

Install pytest:

```bash
pip install pytest
```

Create Test Script:

 ```Python
# test_update_cluster.py
import pytest 
from fabric import Connection 
from update_cluster import read_version_file, check_group_exists, check_user_exists, check_user_in_group, wait_for_server  

@pytest.fixture def conn():
"""
    Fixture to create a connection to the remote server.

    This fixture establishes an SSH connection to the server with the specified
    user and password. It is reused in multiple tests to avoid redundant setup.
    """
     return Connection(host='your_server_ip', user='your_user', connect_kwargs={'password': 'your_password'})
  

def test_connection(conn):
""" 
	Test if the connection with remote server is successful.
	This test ensures that remote server is responsive and ready for testing.
 """
    try:
        conn.run('uptime')
    except Exception as e:
        print(f"Failed to connect to {conn.host}: {e}")
        return False
    return True

def test_connection_failure():
""" 
Test negative case of connetcion. This test checks if connection is not falsified """
    conn = Connection(host='invalid_host', user='your_user', connect_kwargs={'password': 'your_password'})
    with pytest.raises(Exception):
        conn.run('uptime')

# Reading the version file from each server to ensure consistency before performing the update
def read_version_file(conn, file_path):
  """
    Test that the version file is read correctly from the remote server.

    This test ensures that the correct version is returned from the specified
    version file path on the server. The version should match the expected version.
    """
    try:
        result = conn.run(f'cat {file_path}', warn=True)
        return result.stdout.strip()
    except Exception as e:
        print(f"Error reading version file {file_path} on {conn.host}: {e}")
        return None
  
def test_update_script(conn):
"""
    Test the update script execution on the remote server.

    This test runs the update script and checks if the output contains
    'Update successful' to confirm that the update was executed properly.
    """
     result = conn.sudo('sh /path/to/update_script.sh expected_version', warn=True)
     assert "Update successful" in result.stdout
  
def test_configuration_persistence(conn):
"""
    Test that the server configuration persists after the update.

    After running the update, this test checks that the server's configuration file
    has not changed and that the expected configuration is present.
    """ 
     result = conn.run('cat /etc/samba/smb.conf')
     assert "your_configuration" in result.stdout
  
def test_group_existence(conn):
"""
	 Test if proper groups remain after upgrade. Groups must be persistant.
 """
     assert check_group_exists(conn, 'your_group')
  
def test_user_existence(conn):
"""
	 Test if regular test-user still exists.
	During upgrade process cionfuiguration should be persitant. It also means the user. 
"""
     assert check_user_exists(conn, 'regular_user')
  
def test_user_in_group(conn):
"""
	Test to ensure user is in the proper group. Another test for config persistance 
"""
     assert check_user_in_group(conn, 'regular_user', 'your_group')
  
def test_wait_for_server(conn):
""" 
	Test awaiting emthod for server. Method is designed to wait for server for specific time returnint either true if server is working or false if still did not got up after restart. 
 """
     assert wait_for_server(conn)  

def test_regular_user_presence():
"""
 	Test if regular user exists and can login to server. After upgrade is expected user and group persistance. We need to ensure that all properly defined users will be able to login.
	This test also ensures that we can run nightly test suite
   """
     conn = Connection(host='your_server_ip', user='regular_user', connect_kwargs={'password': 'regular_user_password'})
     result = conn.run('whoami')
     assert "regular_user" in result.stdout

```

Step 3: Run the Tests Execute the tests using pytest:


```Python
pytest test_update_cluster.py   
 ``` 

Automated tests ensure that our update scripts run correctly and help catch issues early on, before they cause problems in production.

Summary

Fabric has been a game-changer in server management, making it simpler and more efficient. It's great for everything from SSH connections to running commands and managing clusters. It's a flexible tool that automates the boring stuff, and when paired with pytest, you can trust your scripts to work smoothly.

Why not give it a whirl? Try automating a task with Fabric and explore features like parallel execution. It's a practical way to see the benefits for yourself!


If you're new to server automation, start small. Automate a simple task with Fabric, and once you're comfortable, you can scale up to larger, more complex processes.


Additional Read:

  • Paramiko: Paramiko is a Python library that provides an interface for making SSH connections and executing remote commands via SSH. It is not directly built on top of subprocess and ssh, but instead, it implements its own SSH2 protocol and handles cryptographic aspects of the connection internally using pure Python and libraries like Cryptography.

  • Fabric: Fabric is built on top of Paramiko. It simplifies the process of making SSH connections and executing remote commands by providing a higher-level API. Essentially, Fabric is an abstraction that makes SSH and command execution easier by leveraging Paramiko under the hood.

  • Subprocess and SSH: While Paramiko does not directly rely on subprocess or the system's native SSH, libraries like subprocess can be used to invoke shell commands locally or interact with external programs like ssh. However, this is different from how Paramiko and Fabric work—they open SSH connections programmatically without relying on the underlying shell.


Comments


Subscribe to QABites newsletter

Thanks for submitting!

  • Twitter
  • Facebook
  • Linkedin

© 2023 by QaBites. Powered and secured by Wix

bottom of page