mystack-rig 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/vpn.py ADDED
@@ -0,0 +1,323 @@
1
+ """
2
+ WireGuard VPN Management Module for Remote Development Environments
3
+ """
4
+ import os
5
+ import subprocess
6
+ import json
7
+ import tempfile
8
+ import time
9
+ from typing import Optional, Dict, Any, List
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class WireGuardConfig:
15
+ """WireGuard configuration for a VPN tunnel"""
16
+ interface_name: str = "wg0"
17
+ private_key: str = ""
18
+ public_key: str = ""
19
+ allowed_ips: List[str] = field(default_factory=lambda: ["0.0.0.0/0", "::0/0"])
20
+ endpoint: str = ""
21
+ persistent_keepalive: int = 25
22
+ ip_address: str = ""
23
+ subnet_mask: str = "24"
24
+
25
+
26
+ class VPNManager:
27
+ """Manages WireGuard VPN connections for remote development environments"""
28
+
29
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
30
+ self.config = config or {}
31
+ self.interface_name = self.config.get('interface_name', 'wg0')
32
+ self.vpn_configs: Dict[str, WireGuardConfig] = {}
33
+ self._config_paths: Dict[str, str] = {}
34
+
35
+ def generate_wireguard_keys(self) -> Dict[str, str]:
36
+ """Generate WireGuard key pair"""
37
+ try:
38
+ # Generate private key
39
+ result = subprocess.run(
40
+ ['wg', 'genkey'],
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=10
44
+ )
45
+ if result.returncode != 0:
46
+ raise RuntimeError(f"Failed to generate private key: {result.stderr}")
47
+ private_key = result.stdout.strip()
48
+
49
+ # Generate public key from private key
50
+ result = subprocess.run(
51
+ ['wg', 'pubkey'],
52
+ input=private_key,
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=10
56
+ )
57
+ if result.returncode != 0:
58
+ raise RuntimeError(f"Failed to generate public key: {result.stderr}")
59
+ public_key = result.stdout.strip()
60
+
61
+ return {
62
+ 'private_key': private_key,
63
+ 'public_key': public_key
64
+ }
65
+ except FileNotFoundError:
66
+ raise RuntimeError("WireGuard tools not found. Please install wireguard-tools")
67
+
68
+ def create_wireguard_config(self, name: str, config: Dict[str, Any]) -> WireGuardConfig:
69
+ """Create a WireGuard configuration from dict"""
70
+ wg_config = WireGuardConfig(
71
+ interface_name=config.get('interface_name', self.interface_name),
72
+ private_key=config.get('private_key', ''),
73
+ public_key=config.get('public_key', ''),
74
+ allowed_ips=config.get('allowed_ips', ['0.0.0.0/0', '::0/0']),
75
+ endpoint=config.get('endpoint', ''),
76
+ persistent_keepalive=config.get('persistent_keepalive', 25),
77
+ ip_address=config.get('ip_address', ''),
78
+ subnet_mask=config.get('subnet_mask', '24')
79
+ )
80
+ self.vpn_configs[name] = wg_config
81
+ return wg_config
82
+
83
+ def generate_wireguard_conf(self, wg_config: WireGuardConfig) -> str:
84
+ """Generate wg.conf format content for a WireGuard interface"""
85
+ # Handle IP address that may already include subnet mask
86
+ ip_addr = wg_config.ip_address
87
+ if '/' in ip_addr:
88
+ # IP already has subnet mask, use it as-is
89
+ address = ip_addr
90
+ else:
91
+ # Add subnet mask
92
+ address = f"{ip_addr}/{wg_config.subnet_mask}"
93
+
94
+ conf_lines = [
95
+ f"[Interface]",
96
+ f"PrivateKey = {wg_config.private_key}",
97
+ f"Address = {address}",
98
+ f"ListenPort = 51820",
99
+ "",
100
+ f"[Peer]",
101
+ f"PublicKey = {wg_config.public_key}",
102
+ f"AllowedIPs = {', '.join(wg_config.allowed_ips)}",
103
+ f"Endpoint = {wg_config.endpoint}",
104
+ f"PersistentKeepalive = {wg_config.persistent_keepalive}"
105
+ ]
106
+ return '\n'.join(conf_lines)
107
+
108
+ def _config_path(self, name: str) -> str:
109
+ """Path wg-quick will accept.
110
+
111
+ wg-quick refuses a config whose mode and parent directory are both
112
+ world-accessible, and reading a config from /tmp can fail outright.
113
+ Root writes under /etc/wireguard; other users get a private directory.
114
+ The filename is the interface name, which must stay within 15 characters.
115
+ """
116
+ if os.geteuid() == 0:
117
+ directory = "/etc/wireguard"
118
+ else:
119
+ directory = os.path.join(tempfile.gettempdir(), f"rig-wg-{os.getuid()}")
120
+ os.makedirs(directory, mode=0o700, exist_ok=True)
121
+ if directory != "/etc/wireguard":
122
+ os.chmod(directory, 0o700)
123
+ return os.path.join(directory, f"wg-{name}.conf")
124
+
125
+ def save_wireguard_config(self, name: str, wg_config: WireGuardConfig,
126
+ path: Optional[str] = None) -> str:
127
+ """Save WireGuard configuration to a mode-0600 file"""
128
+ if path is None:
129
+ path = self._config_path(name)
130
+ else:
131
+ parent = os.path.dirname(path) or "."
132
+ os.makedirs(parent, mode=0o700, exist_ok=True)
133
+
134
+ conf_content = self.generate_wireguard_conf(wg_config)
135
+ # O_CREAT honors umask, and O_TRUNC keeps a previous mode, so chmod after.
136
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
137
+ with os.fdopen(fd, 'w') as handle:
138
+ handle.write(conf_content)
139
+ os.chmod(path, 0o600)
140
+ self._config_paths[name] = path
141
+ return path
142
+
143
+ def setup_vpn_tunnel(self, name: str, wg_config: WireGuardConfig) -> bool:
144
+ """Setup a WireGuard VPN tunnel using wg-quick"""
145
+ try:
146
+ # Generate config file
147
+ config_path = self.save_wireguard_config(name, wg_config)
148
+
149
+ # Apply configuration using wg-quick
150
+ result = subprocess.run(
151
+ ['wg-quick', 'up', config_path],
152
+ capture_output=True,
153
+ text=True,
154
+ timeout=30
155
+ )
156
+
157
+ if result.returncode == 0:
158
+ print(f"✓ VPN tunnel '{name}' established successfully")
159
+ return True
160
+ else:
161
+ print(f"✗ Failed to setup VPN tunnel '{name}' ({config_path}): {result.stderr}")
162
+ return False
163
+
164
+ except FileNotFoundError:
165
+ print("Error: wg-quick not found. Please install wireguard-tools")
166
+ return False
167
+ except Exception as e:
168
+ print(f"Error setting up VPN tunnel '{name}': {e}")
169
+ return False
170
+
171
+ def teardown_vpn_tunnel(self, name: str) -> bool:
172
+ """Tear down a WireGuard VPN tunnel"""
173
+ try:
174
+ config_path = self._config_paths.get(name) or self._config_path(name)
175
+
176
+ result = subprocess.run(
177
+ ['wg-quick', 'down', config_path],
178
+ capture_output=True,
179
+ text=True,
180
+ timeout=30
181
+ )
182
+
183
+ if result.returncode == 0:
184
+ print(f"✓ VPN tunnel '{name}' torn down successfully")
185
+ return True
186
+ else:
187
+ print(f"✗ Failed to teardown VPN tunnel '{name}' ({config_path}): {result.stderr}")
188
+ return False
189
+
190
+ except Exception as e:
191
+ print(f"Error tearing down VPN tunnel '{name}': {e}")
192
+ return False
193
+
194
+ def check_vpn_status(self, name: str) -> Dict[str, Any]:
195
+ """Check status of a WireGuard VPN tunnel"""
196
+ try:
197
+ # Get interface status using wg command
198
+ result = subprocess.run(
199
+ ['wg', 'show', 'interfaces'],
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=10
203
+ )
204
+
205
+ if result.returncode == 0:
206
+ interfaces = result.stdout.strip().split('\n')
207
+ is_up = name in interfaces
208
+
209
+ # Get detailed interface info
210
+ detail_result = subprocess.run(
211
+ ['wg', 'show', name],
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=10
215
+ )
216
+
217
+ return {
218
+ 'name': name,
219
+ 'up': is_up,
220
+ 'status': detail_result.stdout if is_up else 'not active',
221
+ 'error': detail_result.stderr if not is_up else None
222
+ }
223
+ else:
224
+ return {
225
+ 'name': name,
226
+ 'up': False,
227
+ 'status': 'unknown',
228
+ 'error': result.stderr
229
+ }
230
+
231
+ except Exception as e:
232
+ return {
233
+ 'name': name,
234
+ 'up': False,
235
+ 'status': 'error',
236
+ 'error': str(e)
237
+ }
238
+
239
+ def list_active_tunnels(self) -> List[str]:
240
+ """List all active WireGuard tunnels"""
241
+ try:
242
+ result = subprocess.run(
243
+ ['wg', 'show', 'interfaces'],
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=10
247
+ )
248
+
249
+ if result.returncode == 0:
250
+ return [iface.strip() for iface in result.stdout.strip().split('\n') if iface]
251
+ return []
252
+ except Exception:
253
+ return []
254
+
255
+ def setup_vpn_with_credentials(self, name: str, endpoint: str,
256
+ private_key: str, peer_public_key: str,
257
+ allowed_ips: Optional[List[str]] = None) -> bool:
258
+ """Setup VPN tunnel using provided credentials"""
259
+ # Generate key pair for local interface
260
+ keys = self.generate_wireguard_keys()
261
+
262
+ # Create config
263
+ wg_config = WireGuardConfig(
264
+ interface_name=name,
265
+ private_key=keys['private_key'],
266
+ public_key=peer_public_key,
267
+ allowed_ips=allowed_ips or ["0.0.0.0/0", "::0/0"],
268
+ endpoint=endpoint,
269
+ ip_address="10.0.0.2/24" # Default IP for dev tunnel
270
+ )
271
+
272
+ return self.setup_vpn_tunnel(name, wg_config)
273
+
274
+
275
+ def setup_remote_vpn_connection(vpn_config: Dict[str, Any]) -> bool:
276
+ """
277
+ Setup VPN connection to remote gateway for accessing dependent services
278
+
279
+ This function creates a WireGuard tunnel to a remote VPN gateway that
280
+ provides access to development services that may be behind firewalls.
281
+
282
+ Args:
283
+ vpn_config: Dictionary containing VPN configuration:
284
+ - endpoint: VPN gateway endpoint (host:port)
285
+ - private_key: Local WireGuard private key
286
+ - peer_public_key: Remote gateway's public key
287
+ - allowed_ips: IPs/ranges accessible through VPN
288
+ - interface_name: Name of the WireGuard interface
289
+
290
+ Returns:
291
+ bool: True if VPN tunnel was established successfully
292
+ """
293
+ manager = VPNManager()
294
+
295
+ try:
296
+ # Generate local keys if not provided
297
+ if not vpn_config.get('private_key') or not vpn_config.get('peer_public_key'):
298
+ keys = manager.generate_wireguard_keys()
299
+ vpn_config['private_key'] = keys['private_key']
300
+
301
+ # Create and setup VPN config
302
+ wg_config = manager.create_wireguard_config(
303
+ vpn_config.get('interface_name', 'wg0'),
304
+ vpn_config
305
+ )
306
+
307
+ return manager.setup_vpn_tunnel(vpn_config.get('interface_name', 'wg0'), wg_config)
308
+
309
+ except Exception as e:
310
+ print(f"Error setting up VPN connection: {e}")
311
+ return False
312
+
313
+
314
+ def get_vpn_status(interface_name: str = "wg0") -> Dict[str, Any]:
315
+ """Get status of VPN connection"""
316
+ manager = VPNManager()
317
+ return manager.check_vpn_status(interface_name)
318
+
319
+
320
+ def teardown_vpn_connection(interface_name: str = "wg0") -> bool:
321
+ """Tear down VPN connection"""
322
+ manager = VPNManager()
323
+ return manager.teardown_vpn_tunnel(interface_name)
@@ -0,0 +1,285 @@
1
+ Metadata-Version: 2.4
2
+ Name: mystack-rig
3
+ Version: 1.0.0
4
+ Summary: Ephemeral remotes with full local parity for coding agents
5
+ Author: mystack-cloud
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mystack-cloud/rig
8
+ Project-URL: Source, https://github.com/mystack-cloud/rig
9
+ Project-URL: Issues, https://github.com/mystack-cloud/rig/issues
10
+ Keywords: remote-development,coding-agents,hetzner
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.7
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: requests>=2.25.0
17
+ Requires-Dist: ansible>=4.0.0
18
+ Requires-Dist: boto3>=1.26.0
19
+ Requires-Dist: PyYAML>=6.0
20
+ Requires-Dist: cryptography>=41.0.0
21
+ Requires-Dist: importlib-metadata>=4.0; python_version < "3.8"
22
+ Dynamic: license-file
23
+
24
+ [![Tests](https://github.com/mystack-cloud/rig/actions/workflows/tests.yml/badge.svg)](https://github.com/mystack-cloud/rig/actions/workflows/tests.yml)
25
+ [![codecov](https://codecov.io/gh/mystack-cloud/rig/graph/badge.svg)](https://codecov.io/gh/mystack-cloud/rig)
26
+
27
+ # rig
28
+
29
+ Secure, fast, cost-efficient ephemeral remotes with full local parity—so coding agents and humans can develop and e2e-test for real.
30
+
31
+ ```bash
32
+ pip install mystack-rig
33
+ rig init
34
+ rig create
35
+ ```
36
+
37
+ The published project name is `mystack-rig` (the PyPI name `rig` belongs to another project). The command is still `rig`. From a checkout, `pip install .` installs the same command.
38
+
39
+ ## Architecture
40
+
41
+ Run `rig` from your machine, CI/CD, or a control plane: it provisions cloud VMs you can create and dispose anytime, and you work on `/workspace` via agent TTY or editor. SSH/HTTPS reaches VMs through the firewall; HTTPS reaches the provider. The CLI stores env state in S3-compatible object storage.
42
+
43
+ ![rig architecture](docs/architecture.png)
44
+
45
+ ## Cloud providers
46
+
47
+ | Provider | `host.provider` | Status |
48
+ | --- | --- | --- |
49
+ | [Hetzner Cloud](https://www.hetzner.com/cloud) | `hetzner` (default) | Supported (VMs, firewall, snapshots, S3-compatible object storage) |
50
+ | [AWS](https://aws.amazon.com/) | `aws` | Coming next |
51
+ | [GCP](https://cloud.google.com/) | `gcp` | Coming next |
52
+
53
+ `rig create` reads `host.provider`. Omit it and the provider is Hetzner. `--provider` overrides the file. `aws` and `gcp` are accepted as names and refused until those clouds can provision.
54
+
55
+ ## Coding agents
56
+
57
+ Chosen at `rig init` (`agent.enabled` in `rig.yml`, same multi-select as git projects: numbers, names, or `all`). Every selected agent is installed and configured on the VM. When more than one is selected, init asks which is the default for `rig attach` (`agent.default`). Pass `--agent` to open another one.
58
+
59
+ | Agent | `agent.default` | API key |
60
+ | --- | --- | --- |
61
+ | [OpenCode](https://opencode.ai) | `opencode` (default) | Optional. One agent: `coding_agent_api_key`. Several agents: `opencode_api_key`. Enter skips and uses free Zen models |
62
+ | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | One agent: `coding_agent_api_key` (Anthropic). Several agents: `claude_api_key` |
63
+ | [Cursor CLI](https://cursor.com/docs/cli/overview) | `cursor` | One agent: `coding_agent_api_key` (`CURSOR_API_KEY`). Several agents: `cursor_api_key` |
64
+
65
+ Each selected agent is asked for its own key. Scaffolds land under `.rig/.opencode/`, `.rig/.claude/`, and `.rig/.cursor/` (plus shared `AGENTS.md`). Independent of `editor` (local IDE for `rig dev`).
66
+
67
+ ## IDEs
68
+
69
+ `rig dev` opens the remote `/workspace`. Default is local editor + Remote-SSH; `--browser` uses code-server on the VM.
70
+
71
+ | Mode | Clients |
72
+ | --- | --- |
73
+ | Remote-SSH (default) | VS Code (`code`), VS Code Insiders (`code-insiders`), Cursor (`cursor`), VSCodium (`codium`) |
74
+ | Browser (`--browser`) | code-server (password: vaulted `vscode_browser_password`) |
75
+
76
+ Chosen at `rig init` (`editor.enabled` in `rig.yml`, same multi-select as git projects: numbers, names, or `all`). When more than one is selected, init asks which is the default for `rig dev` (`editor.default`). Pass `--editor` to open another enabled one. These are local clients: nothing extra is installed on the VM, and each IDE downloads its own remote server the first time it connects. Browser login: `rig vars decrypt vscode_browser_password`.
77
+
78
+ ## Layout
79
+
80
+ `rig init` writes commit-friendly files into the current directory (including a subdirectory of a git repo):
81
+
82
+ ```text
83
+ rig.yml # workspace manifest (vm, agent, playbooks, repos)
84
+ .gitignore # adds .rig/collections, roles, inventory (via init)
85
+ .rig/
86
+ provision.yml # managed; create base environment (cached)
87
+ configure.yml # user; image/service customizations (cached)
88
+ authorize.yml # managed; inject credentials (never snapshotted)
89
+ materialize.yml # user; populate /workspace after authorize
90
+ finalize.yml # user; destruction hooks before managed destroy
91
+ destroy.yml # managed; teardown system resources before VM delete
92
+ vars.yml # Shared Ansible vars (vaulted with shared data key)
93
+ vars.<identity>.yml # Personal secrets + master-key wraps (committed)
94
+ requirements.yml # Galaxy collections/roles
95
+ local_roles/ # committed Ansible roles (searched first)
96
+ AGENTS.md # agent instructions
97
+ .opencode/ | .claude/ | .cursor/ # agent config + MCP
98
+ inventory.yml # generated at create/apply (gitignored)
99
+ collections/ roles/ # Galaxy installs (gitignored)
100
+ ```
101
+
102
+ Put a committed role at `.rig/local_roles/<name>/` (`tasks/main.yml` and the usual role layout). `rig init` creates the directory. Galaxy roles install into `.rig/roles/` and stay gitignored. Ansible checks that directory before the configured path, so each committed role is linked there and wins over a Galaxy role of the same name. An older `.rig/galaxy_roles/` directory is moved into `roles` on the next init or apply.
103
+
104
+ `rig.yml` defaults (edit to configure):
105
+
106
+ ```yaml
107
+ host:
108
+ provider: hetzner # hetzner (default) | aws | gcp
109
+ type: vm # kind of machine; vm is the default
110
+ server_type: cax11 # Hetzner server type
111
+ availability_zone: hel1
112
+ image: ubuntu-24.04
113
+ firewall:
114
+ mode: dynamic_add # open | static | dynamic_replace | dynamic_add
115
+ allowed_ips: [] # static allow-list; also merged into dynamic modes
116
+ ports: [] # extra inbound ports, e.g. [8080, "3000-3001", {port: 53, protocol: udp}]
117
+ agent:
118
+ default: opencode # default for `rig attach`; or claude | cursor
119
+ enabled: [opencode] # agents installed on the VM
120
+ editor:
121
+ default: code # default for `rig dev`
122
+ enabled: [code] # local IDEs `rig dev --editor` may open
123
+ playbooks:
124
+ provision: .rig/provision.yml
125
+ configure: .rig/configure.yml
126
+ authorize: .rig/authorize.yml
127
+ materialize: .rig/materialize.yml
128
+ finalize: .rig/finalize.yml
129
+ destroy: .rig/destroy.yml
130
+ ```
131
+
132
+ Firewall modes:
133
+
134
+ | Mode | Behavior |
135
+ | --- | --- |
136
+ | `open` | SSH/ICMP from anywhere (`0.0.0.0/0`) |
137
+ | `static` | Only `allowed_ips` (no client detection) |
138
+ | `dynamic_replace` | Set allow-list to current CLI public IP (+ `allowed_ips`); drops previous IPs |
139
+ | `dynamic_add` (default) | Keep existing allow-list entries; add current CLI public IP if missing |
140
+
141
+ Attach / apply / dev / destroy refresh the firewall for dynamic modes when the client IP changes, and keep `ports` in sync.
142
+
143
+ `firewall.ports` opens extra inbound listeners with the same source-IP policy as SSH (TCP by default; use `{port, protocol}` for UDP).
144
+
145
+ Remote project files live under `/workspace` (not `/root`).
146
+
147
+ ## Lifecycle
148
+
149
+ ```text
150
+ init → create → attach | dev | apply → destroy
151
+ ↓
152
+ snapshots (reuse on next create)
153
+
154
+ create playbooks:
155
+ provision → configure → [snapshot] → authorize → materialize
156
+
157
+ destroy playbooks:
158
+ finalize → destroy → (delete VM / firewall / key)
159
+ ```
160
+
161
+ | Stage | File | Owner | Meaning |
162
+ | --- | --- | --- | --- |
163
+ | **provision** | `provision.yml` | managed | Create managed base environment |
164
+ | **configure** | `configure.yml` | user | Apply user image/service customizations (cached) |
165
+ | **authorize** | `authorize.yml` | managed | Inject credentials and establish access |
166
+ | **materialize** | `materialize.yml` | user | Populate `/workspace` |
167
+ | **finalize** | `finalize.yml` | user | Run user-defined destruction hooks |
168
+ | **destroy** | `destroy.yml` | managed | Remove managed environment (before CLI deletes VM) |
169
+
170
+ | Command | What happens |
171
+ | --- | --- |
172
+ | **init** | Scaffold `rig.yml` + `.rig/`. Collect Hetzner creds, repo PATs, agent choices and a key per agent. |
173
+ | **create** | Boot VM (base/system snapshot). Run provision → configure → authorize → materialize. Snapshot after **configure** only. |
174
+ | **apply** | Default: authorize → materialize. Flags: `--provision` / `--configure` / `--authorize` / `--materialize`. |
175
+ | **attach** | SSH TTY into `opencode` / `claude` / Cursor `agent` on `/workspace`. `--agent` picks among installed agents. |
176
+ | **dev** | Open VS Code/Cursor via Remote-SSH on `/workspace` (or `--browser` for code-server). |
177
+ | **destroy** | Run finalize → destroy playbooks, then delete VM / firewall / SSH key. |
178
+
179
+ Put secret-free installs in `configure.yml` (cached). Put clone/setup that needs credentials in `materialize.yml`.
180
+
181
+ Last env name is remembered in `/tmp/.rig/last-env` (used when `name` is omitted).
182
+
183
+ After you leave an agent TTY, rig captures the printed session / resume id (when the agent prints one) under `/tmp/.rig/sessions/<env>`. A later bare `rig attach` reopens that session (`--session` for OpenCode, `--resume` for Claude Code / Cursor). Pass `-c` / `--session` / `--resume` to override.
184
+
185
+ ### Snapshots
186
+
187
+ | Boot source | Skips |
188
+ | --- | --- |
189
+ | `rig-{version}-{hash}` | provision + configure (`authorize` + `materialize` still run) |
190
+ | base image | nothing |
191
+
192
+ **System hash** = var map (`coding_agent_type`, `coding_agent_types`, `git_providers`, `code_server_*`, `workspace_root`) + `provision.yml` + `configure.yml`. Secrets and `authorize.yml` / `materialize.yml` are not included.
193
+
194
+ Override with `--force-playbooks` / `--force-system` (force provision+configure) / `--force-workspace` (force materialize).
195
+
196
+ `rig cache list|clear` — show snapshots (and for clear, confirm then delete):
197
+
198
+ | Flags | Selection |
199
+ | --- | --- |
200
+ | (default) | unused system (+ legacy workspace) for current project |
201
+ | `--system` | system only |
202
+ | `--workspace` | legacy workspace snapshots (current project) |
203
+ | `--all` | include current (not only unused) |
204
+ | `--all --system` / `--all --workspace` | all of that kind, including current |
205
+
206
+ `-y` skips the confirmation on clear.
207
+
208
+ ## CLI
209
+
210
+ Global: `-v` playbook tasks and debug · `-vv`/`-vvv`/`-vvvv` ansible verbosity · `-q` warnings/errors only · `--version`. A failed task is shown without `-v`.
211
+
212
+ | Command | Args | Notes |
213
+ | --- | --- | --- |
214
+ | `init` | | Interactive scaffold |
215
+ | `create` | `[--name]` `[--provider]` `[--type]` `[--availability-zone]` `[--image]` `[--no-attach]` `[--force-playbooks\|--force-system\|--force-workspace]` | Host defaults from `rig.yml`. `--provider` defaults to `hetzner`. `--type` sets `host.server_type`. `host.type` defaults to `vm`. `--region` aliases `--availability-zone`. |
216
+ | `apply` | `[name]` `[--provision\|--configure\|--authorize\|--materialize]` | Default: authorize then materialize |
217
+ | `attach` | `[name]` `[-c\|--continue]` `[-s\|--session ID]` `[--resume [ID]]` `[--agent opencode\|claude\|cursor]` `[-- …agent args]` | Reopens last remembered session by default; `--agent` picks which installed agent |
218
+ | `ssh` | `[name]` `[-- command]` | Shell on the VM in `/workspace`. A command after `--` runs instead of a login shell |
219
+ | `dev` | `[name]` `[--path /workspace]` `[--browser]` `[--editor code\|cursor\|…]` | Remote-SSH by default; `--editor` opens another IDE from `editor.enabled`. Defaults to `editor.default` |
220
+ | `list` | | Lists envs, or `No remote environments` |
221
+ | `destroy` | `[name …]` | Runs finalize then destroy playbooks first; multi-name OK |
222
+ | `cache` | `list\|clear` `[--system\|--workspace] [--all] [-y]` | List or clear; unused by default, `--all` includes current |
223
+ | `vars encrypt` | `[--stdin-name VAR]` `[--personal]` | Vault into shared or identity vars (after master-key unlock) |
224
+ | `vars decrypt` | `[VAR]` | Decrypt from shared or identity vars |
225
+ | `vars list` | | List vaulted names (`shared` / `identity`) |
226
+ | `vars export-shared-key` | | Print shared data key for teammate onboarding |
227
+
228
+ ## Ansible variables
229
+
230
+ Playbooks load `.rig/vars.yml` then `.rig/vars.{identity}.yml` (personal wins), then runtime `rig_*` extras. Prefer `{{ rig_env_name }}` over hard-coding.
231
+
232
+ | Variable | Meaning |
233
+ | --- | --- |
234
+ | `rig_env_name` | Env / VM name (`dev-…`) |
235
+ | `rig_workspace_name` | `rig.yml` `name` |
236
+ | `rig_vm_name` | VM name (usually same as env) |
237
+ | `rig_cloud_provider` | Cloud provider (`host.provider`, default `hetzner`) |
238
+ | `rig_vm_type` | Hetzner server type (`host.server_type`) |
239
+ | `rig_availability_zone` | Hetzner location (`hel1`, …) |
240
+ | `rig_vm_image` | Boot image or snapshot id |
241
+ | `rig_vm_ip` | Public IPv4 |
242
+ | `rig_vm_id` | Hetzner server id |
243
+ | `rig_agent_type` | Default attach agent (`opencode`, `claude`, or `cursor`) |
244
+ | `rig_agent_types` | Agents installed on the VM |
245
+ | `rig_workspace_root` | `/workspace` |
246
+ | `rig_version` | Installed `rig` package version |
247
+ | `rig_boot_source` | `base` \| `system` \| `workspace` |
248
+ | `rig_action` | `create` \| `apply` \| `destroy` |
249
+ | `rig_project_dir` | Local project path |
250
+ | `rig_ssh_key_name` / `rig_ssh_key_path` | Local key under `/tmp` |
251
+ | `rig_firewall_name` | Firewall resource name |
252
+ | `rig_repos` | `[{name, url, path}, …]` (no secrets) |
253
+ | `rig_git_providers` | e.g. `github`, `gitlab` |
254
+
255
+ Also available from vars files (defaults): `workspace_root`, `coding_agent_type`, `coding_agent_types`, `git.clone_repos`. On `rig init`, git PATs and each coding agent API key default to **personal** storage in `vars.{identity}.yml` (you are asked to confirm once); shared secrets such as `vscode_browser_password` live in `vars.yml`.
256
+
257
+ `vscode_browser_password` is auto-generated (vaulted) for code-server browser login (`rig dev --browser`). Reveal with `rig vars decrypt vscode_browser_password` (also printed when opening browser mode).
258
+
259
+ ## Credentials
260
+
261
+ | Source | Purpose |
262
+ | --- | --- |
263
+ | `~/.rig/config` | Hetzner token (+ optional S3) and `identity`. Profile via `RIG_PROFILE`. Env vars override. |
264
+ | Personal master key | Prompted on init/unlock; **never stored**. Unwraps data keys from `vars.{identity}.yml`. |
265
+ | `rig_identity_data_key` / `rig_shared_data_key` | Master-key wraps inside `vars.{identity}.yml` (committed). |
266
+ | `/tmp/.rig/.personal-data-key` / `.shared-data-key` | Materialized Ansible vault passwords after unlock (tmpfs). |
267
+ | `/tmp/<env>-key` | Per-env SSH private key (attach / apply / destroy finalizers). |
268
+
269
+ `RIG_IDENTITY` overrides the configured identity. Teammates joining an existing workspace paste the shared data key once (`rig vars export-shared-key` from an unlocked machine).
270
+
271
+ OpenCode without an API key uses free Zen models. With a key, config switches to the paid provider path. When several agents are selected, keys are `opencode_api_key`, `claude_api_key`, and `cursor_api_key` (`RIG_OPENCODE_API_KEY`, `RIG_ANTHROPIC_API_KEY`, `RIG_CURSOR_API_KEY`). A single agent still uses `coding_agent_api_key` (`RIG_CODING_AGENT_API_KEY`).
272
+
273
+ ## Tests
274
+
275
+ ```bash
276
+ python -m pytest tests/ -m "not requires_credentials"
277
+ ```
278
+
279
+ ## Release
280
+
281
+ Push a version tag to publish that version to PyPI. The package version is the tag without an optional leading `v` (`v0.2.0` publishes `0.2.0`). The workflow reads the GitHub Actions secret `PYPI_API_TOKEN` (a PyPI API token, including the `pypi-` prefix).
282
+
283
+ ## License
284
+
285
+ MIT
@@ -0,0 +1,22 @@
1
+ cli/__init__.py,sha256=-ciwSOiwx1UsWSF9s5yCz9dnIulJIE3ZMLIDLsLQBbc,169
2
+ cli/__main__.py,sha256=B0RRFlUUocxDC_pprm5E6wnbFM3bCAG3KVtRFv8q1zM,87
3
+ cli/agents.py,sha256=-cZp1sXrW3b_Axw5zSiaCqq2YuxJGswQ1MI8CqxerH4,18754
4
+ cli/config.py,sha256=MqA6SOJgK2JjArXdPSlQKHQI8pxRzKRVle6trCdrvGk,9559
5
+ cli/encrypt.py,sha256=bKRtXS1_1zkoESKDSgvQEGvx67J5nn2Kap8DQ3ucIiM,39448
6
+ cli/git.py,sha256=4LZ4jz9gmDaejK0z51uIS4KtiVGcNS5Y6QtoUC-1uRA,12371
7
+ cli/last_env.py,sha256=nIaAulm46qQNkXGQ499zpdUkPgo-u-oCo8GlZOcFKmw,1766
8
+ cli/last_session.py,sha256=6UMsdCsLeRn_lBY4eARtU-lb3kxxPaez5dZdyvex4H8,5775
9
+ cli/log.py,sha256=BvXdmqWuyF5xZglMn5Jj4jArstBl4a7TxdUyRoRDMuc,3078
10
+ cli/main.py,sha256=rIMBjbq5DcsCgwSwAFlyxj3EbB-9m6ENvnRMtvMs3_E,41014
11
+ cli/remote_dev.py,sha256=51rHLXoSg-XyBpV-ul6pw2Rxb2ltpbDL-zi-d1JkZbs,209529
12
+ cli/requirements.txt,sha256=B8FET7ZhGdph7K4ttgPJQ-qJJlkAU2xZGRy8pZ_FZDM,66
13
+ cli/snapshots.py,sha256=EwHsJg6RpkJ_ZFgXOTnNkxZruAgB15sNxREBsv7Uhn4,22554
14
+ cli/storage.py,sha256=YVtN7TxLRBXzXdJDO5LhlouR2sMf8xAo74leAaiYXWQ,15043
15
+ cli/vault_keys.py,sha256=2ihw6Cs4EkEWsLms6r3rfhzU1axgOXxUMwvBUJIG9Vc,14629
16
+ cli/vpn.py,sha256=uDYLIwApxchu0qDEI86_RWF2TF8gG-kZOK4aQXavyps,12065
17
+ mystack_rig-1.0.0.dist-info/licenses/LICENSE,sha256=tElGVkkp8Hajzs6sTCoFB-CHyB-HI0Uef153x8gShyc,1070
18
+ mystack_rig-1.0.0.dist-info/METADATA,sha256=6cq4DCBh0isMgq0z5Ly0xGwbAEpHcm-IC_Abx1lsOBo,15837
19
+ mystack_rig-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
20
+ mystack_rig-1.0.0.dist-info/entry_points.txt,sha256=7rKgUV61sb1aDDLrujIDlYEQ8qvMeu3EPUyTvtGSIDo,38
21
+ mystack_rig-1.0.0.dist-info/top_level.txt,sha256=2ImG917oaVHlm0nP9oJE-Qrgs-fq_fGWgba2H1f8fpE,4
22
+ mystack_rig-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rig = cli.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mystack-cloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cli