# GitHub Issue #945: files.link operation fails for hard link

## Issue Summary

**Reporter:** handcraftedcomputers
**Date:** January 23, 2023
**Status:** Open
**Labels:** bug

The `files.link()` operation fails on subsequent runs when creating hard links (`symbolic=False`). While the hard link is created successfully on the first run, subsequent runs fail with the error:

```
pyinfra error: /tmp/foo-hardlink exists and is not a link
```

### Reproduction Steps

```python
from pyinfra.operations import files

files.file('/tmp/foo')
files.link('/tmp/foo-symlink', target='/tmp/foo')
files.link('/tmp/foo-hardlink', target='/tmp/foo', symbolic=False)
```

- **First run:** All operations succeed
- **Subsequent runs:** The hard link operation fails

### Expected Behavior

The `files.link()` operation should detect that the hard link already exists (by comparing inodes) and skip the creation, reporting the link as already present.

## Root Cause Analysis

### The Problem

The issue lies in how the `Link` fact and the `files.link` operation handle hard links vs symbolic links.

#### 1. Link Fact Detection (`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/files.py`)

The `Link` fact (lines 290-309) inherits from `File` and uses `type = "link"`. The underlying detection uses `stat` and `ls` commands which check the file type flag. The file type flags are defined in `FLAG_TO_TYPE` (lines 42-50):

```python
FLAG_TO_TYPE = {
    "b": "block",
    "c": "character",
    "d": "directory",
    "l": "link",      # Only symbolic links have this flag
    "s": "socket",
    "p": "fifo",
    "-": "file",      # Hard links appear as regular files
}
```

**Key insight:** Hard links are not a special file type in UNIX. They are indistinguishable from regular files when examining file type metadata. A hard link is simply another directory entry pointing to the same inode as the original file. Only symbolic links have a distinct file type (`l`).

When the `Link` fact is queried for a hard link path:
1. `stat` or `ls` returns file type `-` (regular file)
2. The fact returns `False` because the type doesn't match "link"

#### 2. Link Operation Logic (`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py`)

In the `link` operation (lines 1401-1505), the logic at lines 1453-1463:

```python
info = host.get_fact(Link, path=path)

if info is False:  # not a link
    yield from _raise_or_remove_invalid_path(
        "link",
        path,
        force,
        force_backup,
        force_backup_dir,
    )
    info = None
```

When `info is False` (the path exists but is not a symbolic link), and `force=False` (the default), the operation raises:

```python
raise OperationError("{0} exists and is not a {1}".format(path, fs_type))
```

This produces the error: `/tmp/foo-hardlink exists and is not a link`

### Why Symbolic Links Work But Hard Links Don't

- **Symbolic links:** Have file type `l`, so `Link` fact returns the link info dict
- **Hard links:** Have file type `-` (regular file), so `Link` fact returns `False`

## Proposed Fix

The fix requires two main changes:

### 1. Add Inode Fact for Hard Link Detection

Add a new fact to retrieve inode information for a file. This is needed to compare whether two files are hard-linked (same inode).

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/files.py`

Add a new `Inode` fact class:

```python
class Inode(FactBase):
    """
    Returns the inode number of a file, or ``None`` if the file does not exist.
    Used for detecting hard links by comparing inodes between files.
    """

    @override
    def command(self, path):
        return make_formatted_string_command(
            "test -e {0} && ( stat -c '%i' {0} 2>/dev/null || stat -f '%i' {0} ) || true",
            QuoteString(path),
        )

    @override
    def process(self, output) -> Optional[int]:
        if output and output[0]:
            try:
                return int(output[0])
            except ValueError:
                return None
        return None
```

### 2. Modify Link Operation to Handle Hard Links

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

Modify the `link` operation to detect existing hard links by comparing inodes:

```python
@operation()
def link(
    path: str,
    target: str | None = None,
    present=True,
    user: str | None = None,
    group: str | None = None,
    symbolic=True,
    create_remote_dir=True,
    force=False,
    force_backup=True,
    force_backup_dir: str | None = None,
):
    # ... docstring unchanged ...

    path = _validate_path(path)

    if present and not target:
        raise OperationError("If present is True target must be provided")

    info = host.get_fact(Link, path=path)

    # For hard links, check if path exists as a regular file with same inode as target
    if info is False and not symbolic:
        # Import Inode fact (add to imports at top of file)
        from pyinfra.facts.files import Inode

        path_inode = host.get_fact(Inode, path=path)
        target_inode = host.get_fact(Inode, path=target)

        if path_inode is not None and target_inode is not None and path_inode == target_inode:
            # Hard link already exists and points to the correct target
            # Get file info for user/group checking
            file_info = host.get_fact(File, path=path)
            if file_info:
                # Check if user/group need updating
                changed = False
                if (user and file_info["user"] != user) or (group and file_info["group"] != group):
                    yield file_utils.chown(path, user, group, dereference=False)
                    changed = True

                if not changed:
                    host.noop("hard link {0} already exists".format(path))
                return
        # If inodes don't match, fall through to error or force handling

    if info is False:  # not a link (and not an existing correct hard link)
        yield from _raise_or_remove_invalid_path(
            "link",
            path,
            force,
            force_backup,
            force_backup_dir,
        )
        info = None

    # ... rest of the function unchanged ...
```

### 3. Add Inode to Facts Imports

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

Update the imports at the top of the file (around line 43-59) to include `Inode`:

```python
from pyinfra.facts.files import (
    MARKER_BEGIN_DEFAULT,
    MARKER_DEFAULT,
    MARKER_END_DEFAULT,
    Block,
    Directory,
    File,
    FileContents,
    FindFiles,
    FindInFile,
    Flags,
    Inode,  # Add this
    Link,
    Md5File,
    Sha1File,
    Sha256File,
    Sha384File,
)
```

## Alternative Approach

The issue reporter suggested extending the `File` fact to include inode, device ID, and nlink count. This is a more comprehensive approach but requires:

1. Modifying the `FileDict` TypedDict to add new fields
2. Updating the stat parsing regex and logic
3. Potentially breaking changes for existing code that depends on the File fact structure

The proposed fix above is less invasive and specifically addresses the hard link detection issue.

## Related Files

1. `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/files.py` - Facts for file system information
2. `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py` - File operations including `link`

## Testing Considerations

New tests should be added:

1. Test that hard link creation works on first run
2. Test that hard link detection works on subsequent runs (idempotency)
3. Test that mismatched hard links (different inodes) are handled correctly
4. Test user/group updates on existing hard links
5. Test `force=True` behavior with hard links

Example test case:

```python
def test_hardlink_idempotent():
    # First run - creates hard link
    files.file('/tmp/foo')
    files.link('/tmp/foo-hardlink', target='/tmp/foo', symbolic=False)

    # Second run - should be no-op
    files.link('/tmp/foo-hardlink', target='/tmp/foo', symbolic=False)
    # Should not raise error, should report "hard link already exists"
```

## Impact Assessment

- **Severity:** Medium - The operation works on first run but fails on subsequent runs, breaking idempotency which is a core principle of configuration management tools
- **Scope:** Affects all users who use `files.link` with `symbolic=False`
- **Workaround:** Users can use `force=True` but this will unnecessarily recreate the hard link on every run
