# GitHub Issue #940: server.script() - sudo fails to apply, and always re-runs script

**Issue URL:** https://github.com/pyinfra-dev/pyinfra/issues/940
**Status:** Open
**Reporter:** @gnat
**Labels:** bug, operations
**Created:** January 12, 2023
**Last Updated:** February 3, 2024

## Issue Summary

The `server.script()` operation has two related problems:

1. **Script always re-runs**: The script is uploaded and executed on every pyinfra run, even if the script content hasn't changed.

2. **Sudo within scripts fails**: When a script contains `sudo` commands internally (not using pyinfra's `_sudo=True`), the sudo prompts don't work correctly and cause the script to hang or fail.

### Reproduction

```python
# tasks/test.py
server.script('./tasks/test.sh')
```

```bash
# tasks/test.sh
#!/bin/bash
echo "hi $USER"
sudo echo "hello $USER"
```

**Expected Behavior:**
- Script should not re-run if content hasn't changed
- `sudo` inside the script should prompt for password or use cached credentials

**Actual Behavior:**
- Script re-runs every time
- Sudo prompts cause the script to hang

---

## Root Cause Analysis

### Problem 1: Script Always Re-Runs

**Location:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/server.py`, line 199

**Current Code:**
```python
@operation(is_idempotent=False)
def script(src: str, args=()):
    temp_file = host.get_temp_filename()  # <-- PROBLEM HERE
    yield from files.put._inner(src=src, dest=temp_file)
    yield chmod(temp_file, "+x")
    yield StringCommand(temp_file, *args)
```

**The Problem:**

`host.get_temp_filename()` is called **without any hash_key argument**. Looking at the implementation in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/host.py` (lines 334-353):

```python
def get_temp_filename(
    self,
    hash_key: Optional[str] = None,
    hash_filename: bool = True,
    *,
    temp_directory: Optional[str] = None,
):
    temp_directory = temp_directory or self._get_temp_directory()

    if not hash_key:
        hash_key = str(uuid4())  # <-- RANDOM UUID!

    if hash_filename:
        hash_key = sha1_hash(hash_key)

    return "{0}/pyinfra-{1}".format(temp_directory, hash_key)
```

When no `hash_key` is provided, a **random UUID** is generated. This means:
1. Every run generates a new, different temp file path
2. The `files.put._inner` operation checks if the file exists at that path using `host.get_fact(File, path=dest)`
3. Since the path is always new, the file is always "not found"
4. Therefore, the script is always uploaded and executed

**Contrast with `script_template`:**

```python
@operation(is_idempotent=False)
def script_template(src: str, args=(), **data):
    temp_file = host.get_temp_filename("{0}{1}".format(src, data))  # <-- Uses hash_key!
    yield from files.template._inner(src, temp_file, **data)
    yield chmod(temp_file, "+x")
    yield StringCommand(temp_file, *args)
```

`script_template` correctly passes `"{0}{1}".format(src, data)` as the hash key, making the temp file path **consistent** across runs.

### Problem 2: Sudo Within Scripts Fails

**Location:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/util.py`

This is a more fundamental architectural limitation of pyinfra:

1. **pyinfra's sudo handling is command-level**: When you use `_sudo=True`, pyinfra wraps the entire command with sudo using `make_unix_command()`. This function:
   - Sets up SUDO_ASKPASS for password handling
   - Runs `sudo -H -n` (non-interactive) or `sudo -A -k` (with askpass)

2. **Script-internal sudo is not managed by pyinfra**: When a script internally calls `sudo`, pyinfra has no knowledge of or control over this. The script runs as a subprocess and:
   - Has no access to pyinfra's sudo password mechanism
   - May hang waiting for TTY input that isn't available
   - The progress bar output may interfere with any password prompts

3. **No TTY allocation for scripts**: pyinfra doesn't allocate a PTY for script execution by default, which sudo requires for interactive password prompts.

**Relevant code in `make_unix_command()`:**
```python
if _sudo:
    command_bits.extend(["sudo", "-H"])

    if _sudo_password:
        command_bits.extend(["-A", "-k"])  # use askpass, disable cache
    else:
        command_bits.append("-n")  # disable prompt/interactivity
```

---

## Proposed Fixes

### Fix 1: Make `server.script()` Use Consistent Temp File Paths

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/server.py`

**Current Code (line 199):**
```python
temp_file = host.get_temp_filename()
```

**Proposed Change:**
```python
temp_file = host.get_temp_filename(src)
```

This ensures the same script file always gets the same temp path, allowing `files.put._inner` to properly detect if the file already exists and skip re-uploading.

**Alternative (more thorough):** Include the script's content hash in the key to detect changes:
```python
from pyinfra.api.util import get_file_sha1

# Get hash of script content to detect changes
script_hash = get_file_sha1(src) if os.path.isfile(src) else src
temp_file = host.get_temp_filename("{0}-{1}".format(src, script_hash))
```

This would re-upload only when the script content actually changes.

### Fix 2: Sudo Within Scripts - Documentation and Workaround

The sudo-within-scripts issue is an architectural limitation. The recommended workarounds are:

#### Workaround A: Use `_sudo=True` on the operation
```python
server.script('./tasks/test.sh', _sudo=True)
```
This runs the entire script as root, eliminating the need for internal sudo calls.

#### Workaround B: Use `SUDO_ASKPASS` within scripts
If scripts need selective sudo, users can configure `SUDO_ASKPASS`:
```bash
#!/bin/bash
export SUDO_ASKPASS="/path/to/askpass-script"
echo "hi $USER"
sudo -A echo "hello $USER"
```

#### Workaround C: Use passwordless sudo for specific commands
Configure `/etc/sudoers` to allow specific commands without password.

### Fix 3: Propagate Sudo Password to Script Environment (Enhancement)

A more advanced fix would propagate pyinfra's sudo credentials to the script environment. This would involve:

1. Setting `SUDO_ASKPASS` environment variable pointing to pyinfra's askpass script
2. Setting the password environment variable (`PYINFRA_SUDO_PASSWORD`)
3. Modifying script execution to include these environment variables

**Potential Implementation:**
```python
@operation(is_idempotent=False)
def script(src: str, args=(), _propagate_sudo_askpass=False):
    script_hash = get_file_sha1(src) if os.path.isfile(src) else src
    temp_file = host.get_temp_filename("{0}-{1}".format(src, script_hash))
    yield from files.put._inner(src=src, dest=temp_file)
    yield chmod(temp_file, "+x")

    if _propagate_sudo_askpass and host.connector_data.get("sudo_askpass_path"):
        # Execute script with SUDO_ASKPASS set
        yield StringCommand(
            temp_file, *args,
            _env={
                "SUDO_ASKPASS": host.connector_data["sudo_askpass_path"],
            }
        )
    else:
        yield StringCommand(temp_file, *args)
```

However, this has security implications and would need careful consideration.

---

## Files Requiring Modification

| File | Purpose | Change Required |
|------|---------|-----------------|
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/server.py` | `server.script()` operation | Fix `get_temp_filename()` call to use consistent hash key |

---

## Additional Considerations

### The `is_idempotent=False` Flag

The `server.script()` operation is marked with `is_idempotent=False`, which is correct because:
1. We cannot know what side effects the script will have
2. Even with the same script, results may vary based on system state

However, the **upload** portion should still be idempotent - we shouldn't re-upload an unchanged script.

### Interaction with `files.put._inner`

The `files.put._inner` function in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py` checks:
1. If the remote file exists at `dest`
2. If it exists, compares checksums using `_file_equal(local_sum_path, dest)`

With a consistent temp file path, this check will work correctly, preventing unnecessary re-uploads.

### Related Issues

- **Issue #1125**: Sudo password prompt detection fails on non-English locales - related to sudo handling
- **Issue #305**: Original sudo password issue discussion
- The maintainer (@Fizzadar) has acknowledged this is a feature request that would be "killer" but is challenging to implement

---

## Testing Recommendations

### Test 1: Verify Script Re-Run Fix
```python
# Run twice, verify second run shows "noop: file already uploaded"
server.script('test.sh')
```

### Test 2: Verify Content Change Detection (if implementing content hash)
```python
# Modify test.sh between runs, verify it re-uploads
server.script('test.sh')
```

### Test 3: Existing `_sudo=True` Behavior
```python
# Verify _sudo=True still works as expected
server.script('test.sh', _sudo=True)
```

---

## Conclusion

The "always re-runs" issue has a clear fix: pass the source filename to `get_temp_filename()` to ensure consistent paths across runs.

The sudo-within-scripts issue is a deeper architectural limitation. The current recommendation from maintainers is to use `_sudo=True` on the operation itself rather than embedding sudo calls within scripts. A complete solution would require significant changes to how pyinfra handles script execution and TTY allocation.
