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/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://github.com/mystack-cloud/rig/actions/workflows/tests.yml)
|
|
25
|
+
[](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
|
+

|
|
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,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
|