# Investigation Report: GitHub Issue #546

## Issue Summary

**Title:** No change on execute operations (operations.apk: change detection not working correctly)

**Reporter:** skutter-de
**Created:** 2021-03-11
**Status:** Open
**Labels:** operations, spec, discussion

### Problem Description

When running an `apk.packages` operation with `upgrade=True`, pyinfra always reports "Success" even when no packages were actually upgraded. This causes downstream issues when users check `operation.changed` to conditionally execute other operations (e.g., rebooting after an upgrade).

**User's Example:**
```python
apk.packages(
    name="Refresh repo list",
    update=True
)

update = apk.packages(
    name="Update everything",
    upgrade=True
)

if update.changed:
    server.reboot(delay=5, interval=1, reboot_timeout=300)
```

The server always reboots, even when no packages were upgraded, because pyinfra treats any executed command as a "change."

---

## Root Cause Analysis

### How pyinfra Determines "Change"

pyinfra determines whether an operation "changed" based on whether it yielded any commands to execute. If commands are yielded, it's a change; if no commands are yielded (typically with a `host.noop()` call), it's "no change."

### The Problem with apk.upgrade()

The current implementation in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/apk.py`:

```python
@operation(is_idempotent=False)
def upgrade(available: bool = False):
    """
    Upgrades all apk packages.

    + available: force all packages to be upgraded (recommended on whole Alpine version upgrades)
    """

    if available:
        yield "apk upgrade --available"
    else:
        yield "apk upgrade"
```

**Key Issues:**

1. **Always yields a command:** The `upgrade()` operation always yields the `apk upgrade` command regardless of whether any packages actually need upgrading.

2. **No pre-check for upgradable packages:** Unlike `apt.upgrade()`, there's no fact-gathering to determine if packages are actually upgradable before running the command.

3. **Marked as `is_idempotent=False`:** While technically correct (running the command always "does something"), this means pyinfra makes no attempt to determine if actual changes will occur.

### How apt.py Solves This

The `apt.py` implementation provides a working pattern via `_simulate_then_perform()`:

```python
def _simulate_then_perform(command: str):
    changes = host.get_fact(SimulateOperationWillChange, command)

    if not changes:
        yield noninteractive_apt(command)
    elif (
        changes["upgraded"] == 0
        and changes["newly_installed"] == 0
        and changes["removed"] == 0
        and changes["not_upgraded"] == 0
    ):
        host.noop(f"{command} skipped, no changes would be performed")
    else:
        yield noninteractive_apt(command)
```

The `SimulateOperationWillChange` fact (in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/apt.py`) runs `apt-get <command> --dry-run` and parses the output to determine if any packages would be upgraded.

### pyinfra's Two-Phase Execution Model Limitation

As the maintainer (Fizzadar) noted in the issue comments, pyinfra has a two-phase execution model:

1. **Fact Gathering Phase:** All facts are gathered and operations are "prepared" (determining commands to run)
2. **Execution Phase:** Commands are actually executed

This creates a limitation: if `apk.update` is called before `apk.upgrade`, the fact-gathering for upgrade happens BEFORE the update actually runs, so any `apk upgrade -s` simulation would show stale results.

However, for the common use case of just `apk.upgrade()` alone, or when the repository list hasn't changed, simulation would still provide correct results.

---

## Proposed Fix

### Option 1: Add ApkUpgradeSimulation Fact (Recommended)

Create a new fact to simulate `apk upgrade` and parse its output.

**New file:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/apk.py`

Add the following:

```python
import re

# Regex to match apk upgrade simulation output
# Example output lines:
# (1/3) Upgrading musl (1.2.2-r0 -> 1.2.2-r1)
# (2/3) Upgrading busybox (1.32.1-r0 -> 1.32.1-r1)
APK_UPGRADE_RE = re.compile(r"^\(\d+/\d+\) Upgrading")


class ApkUpgradeablePackages(FactBase):
    """
    Returns True if there are packages that can be upgraded, False otherwise.

    Uses `apk upgrade -s` (simulate) to check without making changes.
    """

    @override
    def command(self) -> str:
        return "apk upgrade -s 2>/dev/null || true"

    @override
    def requires_command(self) -> str:
        return "apk"

    @override
    def process(self, output) -> bool:
        for line in output:
            if APK_UPGRADE_RE.match(line):
                return True
        return False
```

**Modify:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/apk.py`

```python
from pyinfra.facts.apk import ApkPackages, ApkUpgradeablePackages

@operation()
def upgrade(available: bool = False):
    """
    Upgrades all apk packages.

    + available: force all packages to be upgraded (recommended on whole Alpine version upgrades)
    """

    # Check if there are actually packages to upgrade
    has_upgrades = host.get_fact(ApkUpgradeablePackages)

    if not has_upgrades:
        host.noop("No packages to upgrade")
        return

    if available:
        yield "apk upgrade --available"
    else:
        yield "apk upgrade"
```

**Also update the `update()` operation:**

```python
@operation()
def update():
    """
    Updates apk repositories.
    """
    # Note: Could potentially check repository timestamps, but apk update
    # is fast and should generally always run
    yield "apk update"
```

Note: Remove `is_idempotent=False` from `upgrade()` since it now has proper change detection.

### Option 2: Parse Command Output at Runtime (More Complex)

This would require modifying pyinfra's core to support post-execution change detection, which is more invasive and goes against the current architecture.

### Option 3: Document the Limitation (Minimal)

Update the documentation to clearly explain that `upgrade=True` in `apk.packages()` will always report a change, and suggest users use shell command parsing if they need accurate change detection.

---

## Files to Modify

| File | Changes |
|------|---------|
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/apk.py` | Add `ApkUpgradeablePackages` fact class |
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/apk.py` | Update `upgrade()` to use the new fact for change detection |

---

## Additional Considerations

### Impact on apk.packages() with upgrade=True

The `packages()` function in apk.py calls `_upgrade()` internally:

```python
if upgrade:
    yield from _upgrade()
```

If we fix the standalone `upgrade()` operation, this should automatically fix the `packages(upgrade=True)` behavior.

### Testing Requirements

1. Test that `apk.upgrade()` reports "no change" when no packages need upgrading
2. Test that `apk.upgrade()` reports "success" (change) when packages are actually upgraded
3. Test that `apk.packages(upgrade=True)` properly reflects upgrade changes
4. Test edge case where `apk.update()` is called before `apk.upgrade()` in separate operations

### Backwards Compatibility

This change would be backwards compatible in terms of API, but would change behavior:
- Previously: `apk.upgrade()` always reported "changed"
- After: `apk.upgrade()` reports "no change" when nothing upgraded

This is the expected/correct behavior per the issue, so it should be considered a bug fix rather than a breaking change.

---

## References

- GitHub Issue: https://github.com/pyinfra-dev/pyinfra/issues/546
- Similar pattern in apt.py: `SimulateOperationWillChange` fact
- Alpine apk documentation: `apk upgrade -s` for simulation mode
