Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/012-web-adopt-pairing"
"feature_directory": "specs/013-managed-instance-tags"
}
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,9 @@ remo aws info [--name N] # Show type, cores, memory, EBS size
remo incus create --name <n> [--host H] # Create container
remo incus list # List registered containers
remo incus info --name <n> # Show cores, memory, root size
remo incus sync [--host H] # Discover existing containers
remo incus update --name <n> # Update dev tools
remo incus sync [--host H] # Discover remo-managed containers
remo incus sync [--host H] --all # Also adopt non-remo containers on the host
remo incus update --name <n> # Update dev tools (also marks as remo-managed)
remo incus update --name <n> --volume-size 40 --cores 4 --memory 4096
remo incus destroy --name <n> [--yes] # Destroy container
remo incus bootstrap # Initialize Incus on host
Expand All @@ -265,8 +266,9 @@ remo incus bootstrap # Initialize Incus on host
remo proxmox create --name <n> --host <node> # Create LXC container
remo proxmox list # List registered containers
remo proxmox info --name <n> # Show cores, memory, rootfs size
remo proxmox sync --host <node> # Discover existing containers
remo proxmox update --name <n> # Update dev tools
remo proxmox sync --host <node> # Discover remo-managed containers
remo proxmox sync --host <node> --all # Also adopt non-remo containers on the node
remo proxmox update --name <n> # Update dev tools (also marks as remo-managed)
remo proxmox update --name <n> --volume-size 40 --cores 4 --memory 4096
remo proxmox destroy --name <n> [--yes] [--purge] # Destroy container
remo proxmox bootstrap --host <node> # Verify node + download LXC template
Expand Down Expand Up @@ -353,10 +355,19 @@ discovery states, terminal limits, troubleshooting, and upgrade notes:
```bash
remo aws sync # Discover AWS instances with 'remo' tag
remo hetzner sync # Discover Hetzner VMs with 'remo' label
remo incus sync # Discover Incus containers
remo proxmox sync --host <node> # Discover Proxmox LXC containers
remo incus sync # Discover remo-managed Incus containers
remo proxmox sync --host <node> # Discover remo-managed Proxmox LXC containers
```

All four providers now filter `sync` to the containers/instances **remo created**.
On Incus/Proxmox, `remo`-created containers are marked at provision time (an Incus
`user.remo=true` config key or a Proxmox `remo` guest tag), and a default `sync`
registers only those. To adopt containers `remo` did not create, use
`sync --all` (a one-time, unmarked adoption) or `remo <provider> update <name>`
(permanently marks one). Containers created before this feature are unmarked;
the first default `sync` after upgrading names them and prints both remedies
rather than silently dropping or re-marking them.

**SSH connection fails?**
```bash
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa
Expand Down
59 changes: 59 additions & 0 deletions specs/013-managed-instance-tags/contracts/cli-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# CLI Contract: `sync --all` and filtered-sync output

Applies to `remo incus sync` and `remo proxmox sync`. No other command's flags
change. AWS/Hetzner `sync` is untouched (FR-011).

## New flag

```
--all Register every container discovered on the host, including those
without the remo managed marker (pre-feature behavior). Default off.
```

- Type: boolean `is_flag` (Click), threaded to `providers.<p>.sync(all=<bool>)`.
- Coexists with the existing `--host`, `--user`, `--use-ip` options.

## Behavior contract

| Host state | Command | Registered | stdout hint/summary |
|------------|---------|-----------|---------------------|
| mix of marked + unmarked | `sync` (default) | only marked | names skipped unmarked containers + count + both remedies |
| all marked | `sync` (default) | all | normal `Synced N container(s)…` (no skip hint) |
| all unmarked | `sync` (default) | none | `Synced 0…` + skip hint naming all skipped + remedies |
| any | `sync --all` | all | normal summary; if ≥1 was unmarked, ALSO a line distinguishing the unmarked/adopted count + round-trip warning |

## Output contract — default filtered sync that skipped containers (FR-008)

Must include, in this spirit (exact wording flexible):

```
Synced 1 container(s) from '<host>'.
Skipped 2 unmarked container(s): plex, homeassistant
• Adopt all this run: remo <provider> sync --host <host> --all
• Mark one permanently: remo <provider> update <name>
```

Requirements:
- The skipped container **names** are listed (clarification 1), not just a count.
- Both remedies are named: `--all` and `remo <provider> update <name>`.
- Emitted via `core.output` (`print_info`/`print_warning`), consistent with the
existing `Synced N…` line.

## Output contract — `--all` adopting unmarked containers (FR-009)

```
Synced 3 container(s) from '<host>' (2 not remo-created; adopted via --all).
Note: a later default `sync` will drop the 2 unmarked one(s) again.
```

Requirements:
- The count of registered-but-unmarked containers is distinguished from the
total (FR-009).
- The round-trip behavior is stated plainly (Edge Case: mixed-marker `--all`).

## Non-goals (unchanged behavior)

- Registry line format is unchanged (FR-012).
- `remo shell` / `remo cp` connection path is unchanged (FR-012).
- Lifecycle commands (`destroy`, `snapshot`, resize) gain **no** marker check;
they operate uniformly on any registry entry (clarification 2).
58 changes: 58 additions & 0 deletions specs/013-managed-instance-tags/contracts/marker-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Host-Command Contract: marker apply & read

All commands run through the existing per-host SSH helpers
(`_ssh_run_on_incus_host` / `_ssh_run`), which also handle `host == "localhost"`
for Incus. `<name>`/`<vmid>` are `shlex.quote`d.

## Incus

### Apply marker (create + update) — idempotent (FR-001, FR-002, FR-004)

```
incus config set <name> user.remo=true
```
- Success: rc 0 (no-op when already set — SC-005).
- Failure: warn per FR-005; do not fail the enclosing command on this alone.

### Read markers for sync — single bulk query (FR-013)

```
incus list -f csv -c n,user.remo
```
- Output rows: `<name>,<marker-value>`; `marker-value == "true"` ⇒ marked.
- Default sync keeps marked rows; `--all` keeps all and counts `!= "true"`.
- Fallback (older Incus, if the column form is unreliable): two queries —
`incus list -f csv -c n` (all) and `incus list user.remo=true -f csv -c n`
(marked) — still bounded, still no per-container round-trip.

## Proxmox

### Apply marker (create + update) — union, preserve tags (FR-001..FR-004)

Read current tags, then write only if `remo` is absent:
```
pct config <vmid> # parse the `tags:` line → set
# if "remo" not in set:
pct set <vmid> --tags "<tag1;tag2;remo>" # existing order preserved, remo appended
```
- Separator: split read on `[;, ]+`; join write with `;`.
- `remo` already present ⇒ skip the `pct set` entirely (strict no-op, no
reorder — SC-005, FR-003).
- Failure: warn per FR-005; do not fail the enclosing command on this alone.

### Read markers for sync — inventory + one bulk tag read (FR-013)

```
pct list # existing: vmid + name inventory
grep -H '^tags:' /etc/pve/lxc/*.conf # one round-trip: vmid → tag line
```
- `<vmid>.conf` with `remo` in its tag line ⇒ that container is marked.
- A vmid absent from the grep output (no `tags:` line) ⇒ unmarked.
- Consistent with existing snapshot code that reads `/etc/pve/lxc/<vmid>.conf`.

## Invariants across both providers

- `sync` issues **no** apply/remove/modify command — read-only (FR-010).
- Marker literals come only from `core/config.py` constants (fixed, not
configurable).
- Behavior is identical for localhost and remote Incus hosts.
80 changes: 80 additions & 0 deletions specs/013-managed-instance-tags/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Phase 1 Data Model: Managed-Instance Tagging & Filtered Sync

This feature adds provider-side metadata and a small amount of in-memory
sync-time state. It introduces **no** new persisted local entity and does not
change the `KnownHost` registry representation (FR-012, out-of-scope: registry
does not record marker state).

## Entity: Managed Marker (provider-side, authoritative)

A fixed, built-in piece of container metadata indicating the container was
created and is managed by `remo`. Not user-configurable (clarified).

| Provider | Form | Key/Value | Namespace safety |
|----------|------|-----------|------------------|
| Incus | config key | `user.remo` = `true` | `user.*` is reserved for user metadata; cannot collide with Incus keys |
| Proxmox | guest tag | bare tag `remo` in the tag set | tag is one member of a set; other tags preserved |

**Constants** (single source, `core/config.py`):
- `INCUS_MANAGED_CONFIG_KEY = "user.remo"`
- `INCUS_MANAGED_CONFIG_VALUE = "true"`
- `PROXMOX_MANAGED_TAG = "remo"`

**Lifecycle / state transitions**:

```
unmarked ──create()──▶ marked (FR-001: applied at provision time)
unmarked ──update()──▶ marked (FR-004: backfill; idempotent)
marked ──create()/update()──▶ marked (FR-002: no-op re-apply)
marked ──user removes tag/key manually──▶ unmarked (Edge Case: intentional "unmanage"; sync does not re-add)
```

`sync` never transitions this state (FR-010: read-only on container state).

**Validation / invariants**:
- **Idempotency (FR-002)**: re-applying MUST NOT alter any other config.
- Incus: `incus config set <name> user.remo=true` on an already-set key is a
no-op by construction.
- Proxmox: apply only writes when `remo ∉ tags`; otherwise it is skipped, so
the tag list is never reordered (SC-005).
- **Tag preservation (FR-003)**: Proxmox apply computes `new = existing ∪
{remo}` and writes `;`-joined; no existing tag removed or altered.
- **Apply-failure tolerance (FR-005)**: a failed apply during `create`/`update`
warns but does not, by itself, fail the command when the container was
otherwise created/configured.

## In-memory value: Discovered Container (sync-time, transient)

Produced while scanning a host; never persisted with marker state.

| Field | Type | Source (Incus) | Source (Proxmox) |
|-------|------|----------------|------------------|
| `name` | str | col `n` of `incus list` | Name column of `pct list` |
| `vmid` | str | (n/a) | VMID column of `pct list` |
| `marked` | bool | col `user.remo` == `true` | `remo ∈` tags from `/etc/pve/lxc/<vmid>.conf` |

**Sync selection rule**:
- Default (`all=False`): register iff `marked` is true. Collect names of
`marked == False` into a `skipped` list for the hint.
- `--all` (`all=True`): register every discovered container; collect names of
`marked == False` into an `adopted_unmarked` list for the summary.

## Entity: KnownHost (existing — unchanged)

The registry line format is untouched (FR-012). For reference, marker state is
**not** a field here:
- Incus: `name = "<host>/<container>"`, `instance_id = <host-user>`.
- Proxmox: `name = "<node>/<container>"`, `instance_id = <vmid>`, `region =
<ssh-user>`.

## Relationships

```
KnownHost (registry, connection-only)
│ 1:1 by name
Container (provider-side)
│ carries 0..1
Managed Marker ── authoritative on provider; drives sync inclusion
```
131 changes: 131 additions & 0 deletions specs/013-managed-instance-tags/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Implementation Plan: Managed-Instance Tagging & Filtered Sync (Incus / Proxmox)

**Branch**: `013-managed-instance-tags` | **Date**: 2026-07-22 | **Spec**: [spec.md](./spec.md)

**Input**: Feature specification from `/specs/013-managed-instance-tags/spec.md`

## Summary

Mark `remo`-created Incus and Proxmox containers with a provider-native managed
marker at provision time, and make `sync` filter on that marker by default so it
stops importing every unrelated container on a hypervisor host. This brings the
two hypervisor providers in line with AWS (`tag:remo=true`) and Hetzner
(`label_selector=remo`), which already filter on a native marker.

Technical approach: apply and read the marker **host-side in the Python provider
layer** (`providers/incus.py`, `providers/proxmox.py`), reusing the existing
per-host SSH helpers (`_ssh_run_on_incus_host`, `_ssh_run`). This is required
because `update`'s dev-tools playbook connects to the *container's* IP, not the
hypervisor host — only the Python layer holds a host/node connection at both
`create` and `update` time. The marker literal is a fixed built-in constant
(clarified), so a single shared definition in `core/config.py` keeps Incus and
Proxmox consistent. `sync` gains an `--all` flag that restores today's unfiltered
behavior; the default path filters on the marker and prints an actionable hint
(naming skipped containers) when it skips anything.

## Technical Context

**Language/Version**: Python 3.11+ (existing `remo_cli` src-layout package)

**Primary Dependencies**: Click (CLI), stdlib `subprocess`/`shlex` for host
commands over SSH. No new runtime dependencies. Provider marker mechanics use
the native `incus` and `pct`/`pvesh` CLIs already invoked over SSH.

**Storage**: Marker is authoritative on the **provider side** (Incus `user.*`
config key; Proxmox guest tag). The local flat-file registry
(`~/.config/remo/known_hosts`) is unchanged — it does not record marker state
(explicitly out of scope).

**Testing**: pytest with `mocker.patch` of the provider SSH helpers
(`tests/unit/providers/`, `tests/unit/cli/providers/`), mirroring the existing
snapshot test suites. No live hypervisor required.

**Target Platform**: Linux workstation running the `remo` CLI against Incus
(localhost or remote) and Proxmox nodes over SSH.

**Project Type**: Single-project Python CLI (three-layer: cli/ → providers/ →
core/).

**Performance Goals**: Marker detection during `sync` MUST stay bounded to a
small, constant number of host queries (FR-013) — no per-container round-trip
that scales with unrelated containers.

**Constraints**: Marker application MUST be idempotent (FR-002) and MUST
preserve pre-existing Proxmox tags (FR-003). `sync` MUST remain read-only on
container state (FR-010). AWS/Hetzner behavior MUST NOT change (FR-011).

**Scale/Scope**: Two providers, two commands touched each (`create`, `update`,
`sync`), plus one shared constant and a hint helper. Tens of containers per host
is the realistic ceiling.

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

The constitution is Ansible-centric; this feature is implemented in the Python
provider layer and touches **no Ansible**. The principles still map cleanly:

| Principle | Applies? | How this plan satisfies it |
|-----------|----------|----------------------------|
| I. Defensive Variable Access (Ansible) | N/A | No Ansible tasks added or changed; marker logic lives in Python. |
| II. Test All Conditional Paths | ✅ | Tests cover marked/unmarked/mixed hosts, default vs `--all`, idempotent re-apply, and marker-apply failure (FR-005). Both branches of every new `if all:`/`if marked:` path exercised. |
| III. Idempotent by Default | ✅ | `create`/`update` re-apply is a no-op (Incus: set-same-value; Proxmox: skip write when `remo` already in tag set). Verified by SC-005 (config identical apart from marker). |
| IV. Fail Fast with Clear Messages | ✅ | Marker-apply failure warns with actionable text (FR-005); filtered `sync` prints a named hint with both remedies (FR-008). |
| V. Documentation Reflects Reality | ✅ | README `sync` sections (lines ~236–268, ~354–357) updated to state Incus/Proxmox now filter by default and document `--all`. |

**Gate result: PASS** — no violations, Complexity Tracking not required.

## Project Structure

### Documentation (this feature)

```text
specs/013-managed-instance-tags/
├── plan.md # This file
├── research.md # Phase 0 output — marker mechanics decisions
├── data-model.md # Phase 1 output — marker + entities
├── quickstart.md # Phase 1 output — validation scenarios
├── contracts/ # Phase 1 output
│ ├── cli-sync.md # `--all` flag + sync output contract
│ └── marker-commands.md # host-command contract per provider
└── tasks.md # /speckit-tasks output (NOT created here)
```

### Source Code (repository root)

```text
src/remo_cli/
├── core/
│ └── config.py # + fixed marker constants (single source)
├── providers/
│ ├── incus.py # + _apply_managed_marker(), marker-aware
│ │ # listing; create()/update() apply marker;
│ │ # sync(all=False) filters
│ └── proxmox.py # + _apply_managed_marker() (tag union),
│ # bulk tag read; create()/update() apply;
│ # sync(all=False) filters
├── cli/providers/
│ ├── incus.py # + `--all` flag on `sync`
│ └── proxmox.py # + `--all` flag on `sync`
└── core/
└── output.py # (reuse) print_info/print_warning for hint

tests/unit/
├── providers/
│ ├── test_incus_marker.py # new: apply/idempotency/filtered sync
│ └── test_proxmox_marker.py # new: tag union/preserve/filtered sync
└── cli/providers/
├── test_incus_sync_all.py # new: `--all` flag wiring + hint output
└── test_proxmox_sync_all.py # new: `--all` flag wiring + hint output

README.md # sync docs updated (Principle V)
```

**Structure Decision**: Single-project Python CLI, existing three-layer
architecture. Marker business logic goes in `providers/`, the fixed constant in
`core/config.py`, and only the thin `--all` flag lands in `cli/providers/`. No
new modules or Ansible roles are introduced.

## Complexity Tracking

> No constitution violations — section intentionally empty.
Loading
Loading