[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bitsquare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,59 @@
|
||||
# @bitsquare/cubes-core
|
||||
|
||||
The core cube bundle for [nopy](https://www.npmjs.com/package/@bitsquare/nopy):
|
||||
base packages, users, SSH, firewalling, networking, web serving and runtimes.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm add -D @bitsquare/cubes-core
|
||||
```
|
||||
|
||||
Then name it in `.nopyrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["web-1"],
|
||||
"cubePackages": ["@bitsquare/cubes-core"]
|
||||
}
|
||||
```
|
||||
|
||||
`nopy` resolves the package from the directory of the config file that named it,
|
||||
reads `nopy.cubes` out of its `package.json`, and scans those directories exactly
|
||||
as it scans a `cubeDirs` entry. Nothing has to be linked or copied.
|
||||
|
||||
## What is in it
|
||||
|
||||
| Area | Cube ids |
|
||||
| ---------- | ------------------------------------------------------------------- |
|
||||
| admin | `admin:cockpit`, `admin:hostname`, `admin:locale` |
|
||||
| packages | `apt:essentials`, `apt:install` |
|
||||
| hardening | `armor:fail2ban`, `armor:ssh`, `armor:ufw` |
|
||||
| web | `caddy`, `caddy:spa` |
|
||||
| source | `git:clone` |
|
||||
| networking | `net:tailscale`, `net:wifi:access-point`, `net:wifi:connection` |
|
||||
| runtimes | `runtime:docker`, `runtime:nodevm` |
|
||||
| services | `service:autostart` |
|
||||
| ssh | `ssh:authorize`, `ssh:keygen`, `ssh:keyman` |
|
||||
| users | `user:add`, `user:edit` |
|
||||
|
||||
Run `nopy` and pick from the list, or `nopy -P` to print the pyinfra commands
|
||||
without executing them. Each cube directory has its own `README.md`.
|
||||
|
||||
## Cube ids are global
|
||||
|
||||
An id such as `apt:essentials` is claimed repo-wide, not per bundle: two cubes
|
||||
with the same id — whichever sources they came from — abort the run with an
|
||||
error naming both. Prefix your own cubes distinctly if you also point
|
||||
`cubeDirs` at a local tree.
|
||||
|
||||
## The bundle is read-only
|
||||
|
||||
Under pnpm the installed files are hardlinked into the global store, so a cube
|
||||
that writes next to its own `deploy.py` corrupts that store for every project on
|
||||
the machine. Cubes here write to `/tmp` or to the remote host, never to their
|
||||
own directory.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,76 @@
|
||||
# cockpit
|
||||
|
||||
**Install Cockpit web-based server management interface**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube installs Cockpit, a powerful web-based interface for managing Linux servers, making system administration accessible through your browser.
|
||||
|
||||
## What is Cockpit?
|
||||
|
||||
Cockpit is a modern, interactive server admin interface that runs in your web browser. It provides:
|
||||
|
||||
- **Real-time monitoring**: CPU, memory, disk, and network usage graphs
|
||||
- **Container management**: View and manage Docker containers
|
||||
- **Service management**: Start, stop, and manage systemd services
|
||||
- **Storage administration**: Manage disks, RAID, and filesystems
|
||||
- **Network configuration**: Configure network interfaces and firewall
|
||||
- **Terminal access**: Built-in terminal for command-line access
|
||||
- **User management**: Create and manage user accounts
|
||||
- **Software updates**: View and apply system updates
|
||||
|
||||
Think of it as a control panel for your Linux server - all accessible from any web browser.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Installs the `cockpit` package
|
||||
2. Installs `sscg` (Simple Signed Certificate Generator) for HTTPS support
|
||||
3. Starts the Cockpit service
|
||||
4. Makes Cockpit accessible on port 9090
|
||||
|
||||
## Configuration
|
||||
|
||||
This cube currently has no configurable parameters.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Post-Installation
|
||||
|
||||
Access Cockpit by navigating to:
|
||||
```
|
||||
https://your-server-ip:9090
|
||||
```
|
||||
|
||||
Login with any valid system user account (e.g., root or a user created with the `user-add` cube).
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Cockpit uses HTTPS by default (self-signed certificate)
|
||||
- Your browser will show a security warning on first access (expected with self-signed certs)
|
||||
- Consider using UFW to restrict access: `sudo ufw allow from YOUR_IP to any port 9090`
|
||||
- Disable Cockpit when not in use: `sudo systemctl stop cockpit.socket`
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- Monitor server performance in real-time
|
||||
- Manage Docker containers without command-line
|
||||
- View system logs and troubleshoot issues
|
||||
- Configure network settings
|
||||
- Apply system updates
|
||||
- Manage storage and filesystems
|
||||
|
||||
## Managing Cockpit
|
||||
|
||||
Start/stop Cockpit:
|
||||
```bash
|
||||
sudo systemctl start cockpit.socket
|
||||
sudo systemctl stop cockpit.socket
|
||||
sudo systemctl status cockpit.socket
|
||||
```
|
||||
|
||||
Disable Cockpit from starting on boot:
|
||||
```bash
|
||||
sudo systemctl disable cockpit.socket
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server, apt
|
||||
|
||||
apt.packages(
|
||||
packages=[ "sscg cockpit"],
|
||||
present=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
server.service(
|
||||
'cockpit',
|
||||
running=True,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
|
||||
export default Manifest({
|
||||
id: 'admin:cockpit',
|
||||
name: 'Install cockpit and utils',
|
||||
dependencies: () => [],
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server, files
|
||||
|
||||
"""
|
||||
Deployment script for the admin:hostname cube.
|
||||
Uses pyinfra's server.hostname operation to set and persist the system hostname.
|
||||
"""
|
||||
|
||||
HOSTNAME = host.data.HOSTNAME
|
||||
|
||||
if HOSTNAME:
|
||||
server.hostname(
|
||||
name=f"Set system hostname to {HOSTNAME}",
|
||||
hostname=HOSTNAME,
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
# 2. Update /etc/hosts to prevent "unable to resolve host" errors
|
||||
# This looks for the line starting with 127.0.1.1 and replaces it entirely
|
||||
files.line(
|
||||
name="Update /etc/hosts for local resolution",
|
||||
path="/etc/hosts",
|
||||
line=r"^127\.0\.1\.1\s+.*",
|
||||
replace=f"127.0.1.1 {HOSTNAME}",
|
||||
_sudo=True,
|
||||
)
|
||||
# 3. Restart Avahi (Network Broadcast)
|
||||
# This pushes the name change out to the shared network
|
||||
server.service(
|
||||
name="Restart Avahi to broadcast new mDNS name",
|
||||
service="avahi-daemon",
|
||||
restarted=True,
|
||||
_sudo=True,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Manifest, uniqid } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Manifest for the admin:hostname cube.
|
||||
* This cube allows for setting and persistently changing the system's hostname.
|
||||
*/
|
||||
export default Manifest({
|
||||
id: 'admin:hostname',
|
||||
name: 'Permanently change the hostname',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
HOSTNAME: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.describe('The new hostname for the target host')
|
||||
.default(`host-${uniqid()}`),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
# Cube: admin/locale
|
||||
|
||||
Configures system keyboard layout permanently by updating `/etc/default/keyboard` and using `localectl`.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `LAYOUT` (string): Keyboard layout (e.g. "ch", "us", "de"). Default: "ch".
|
||||
- `MODEL` (string): Keyboard model. Default: "pc105".
|
||||
- `VARIANT` (string): Keyboard variant. Default: "".
|
||||
- `OPTIONS` (string): Keyboard options (comma separated). Default: "".
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
|
||||
export default Manifest({
|
||||
name: 'My Host Setup',
|
||||
dependencies: () => [
|
||||
['admin:locale', { LAYOUT: 'de' }]
|
||||
]
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server, files
|
||||
|
||||
LAYOUT = host.data.LAYOUT
|
||||
MODEL = host.data.MODEL
|
||||
VARIANT = host.data.VARIANT
|
||||
OPTIONS = host.data.OPTIONS
|
||||
|
||||
# Update /etc/default/keyboard
|
||||
files.line(
|
||||
name="Update XKBMODEL in /etc/default/keyboard",
|
||||
path="/etc/default/keyboard",
|
||||
line=r'^XKBMODEL=.*',
|
||||
replace=f'XKBMODEL="{MODEL}"',
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
files.line(
|
||||
name="Update XKBLAYOUT in /etc/default/keyboard",
|
||||
path="/etc/default/keyboard",
|
||||
line=r'^XKBLAYOUT=.*',
|
||||
replace=f'XKBLAYOUT="{LAYOUT}"',
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
files.line(
|
||||
name="Update XKBVARIANT in /etc/default/keyboard",
|
||||
path="/etc/default/keyboard",
|
||||
line=r'^XKBVARIANT=.*',
|
||||
replace=f'XKBVARIANT="{VARIANT}"',
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
files.line(
|
||||
name="Update XKBOPTIONS in /etc/default/keyboard",
|
||||
path="/etc/default/keyboard",
|
||||
line=r'^XKBOPTIONS=.*',
|
||||
replace=f'XKBOPTIONS="{OPTIONS}"',
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
# Apply keyboard configuration
|
||||
server.shell(
|
||||
name="Apply keyboard setup",
|
||||
commands=["setupcon", "service keyboard-setup restart"],
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
# Set X11 keyboard layout using localectl if available
|
||||
server.shell(
|
||||
name="Set X11 keyboard layout using localectl",
|
||||
commands=[f"localectl set-x11-keymap {LAYOUT} {MODEL} '{VARIANT}' '{OPTIONS}'"],
|
||||
_sudo=True,
|
||||
_ignore_errors=True,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'admin:locale',
|
||||
name: 'Configure system locale and keyboard layout',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
LAYOUT: z.string().describe('Keyboard layout (e.g. "ch", "us", "de")').default('ch'),
|
||||
MODEL: z.string().describe('Keyboard model').default('pc105'),
|
||||
VARIANT: z.string().describe('Keyboard variant').default(''),
|
||||
OPTIONS: z.string().describe('Keyboard options (comma separated)').default(''),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
# apt:essentials
|
||||
|
||||
**Install essential packages**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube installs a curated collection of essential development tools and utilities that are commonly needed for server environments and development workflows.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
Installs the following packages via apt:
|
||||
|
||||
- **fish** - User-friendly command-line shell with autosuggestions and syntax highlighting
|
||||
- **ranger** - Terminal-based file manager with vi key bindings
|
||||
- **golang-go** - Go programming language compiler and tools
|
||||
- **build-essential** - Essential compilation tools (gcc, g++, make, etc.)
|
||||
- **python3** - Python 3 interpreter and standard library
|
||||
- **pkg-config** - Helper tool for compiling applications and libraries
|
||||
- **age** - Modern file encryption tool with small explicit keys
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **UPDATE** (boolean, default: `true`)
|
||||
- If already installed, should packages be updated?
|
||||
- Set to `false` to skip package cache updates and only install missing packages
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Use Cases
|
||||
|
||||
This cube is ideal as a base dependency for other cubes that require:
|
||||
- Basic development tools
|
||||
- Modern shell environments
|
||||
- File encryption capabilities
|
||||
- Python or Go runtimes
|
||||
@@ -0,0 +1,18 @@
|
||||
from pyinfra.operations import apt
|
||||
from pyinfra import host
|
||||
|
||||
UPDATE = host.data.UPDATE
|
||||
|
||||
apt.packages(
|
||||
name='Install essentials',
|
||||
packages=[
|
||||
'fish',
|
||||
'build-essential',
|
||||
'python3',
|
||||
'pkg-config',
|
||||
'age'
|
||||
],
|
||||
update=UPDATE,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'apt:essentials',
|
||||
name: 'Install essential packages',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
UPDATE: z.boolean().describe('If already installed should packages be updated').default(true),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# apt
|
||||
|
||||
**Install packages with apt**
|
||||
|
||||
## Purpose
|
||||
|
||||
This is a generic cube for installing custom packages via the apt package manager. It's useful when you need to install specific packages that aren't covered by other specialized cubes.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Optionally updates the apt package cache
|
||||
2. Installs the specified space-separated list of packages
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **UPDATE** (boolean, default: `true`)
|
||||
- Update package cache before installing
|
||||
- Set to `false` to skip updating and only install packages
|
||||
|
||||
- **PACKAGES** (string, default: `''`)
|
||||
- Space-separated list of packages to install
|
||||
- Example: `"vim git htop curl wget"`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Use Cases
|
||||
|
||||
Install development tools:
|
||||
```
|
||||
PACKAGES="vim git htop tmux"
|
||||
```
|
||||
|
||||
Install database clients:
|
||||
```
|
||||
PACKAGES="postgresql-client mysql-client redis-tools"
|
||||
```
|
||||
|
||||
Install system utilities:
|
||||
```
|
||||
PACKAGES="curl wget jq unzip zip"
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
When deploying this cube, you would typically configure it like:
|
||||
```javascript
|
||||
exec('apt', {
|
||||
PACKAGES: 'nginx certbot python3-certbot-nginx',
|
||||
UPDATE: true
|
||||
})
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Package names must match exact apt package names
|
||||
- Invalid package names will cause the installation to fail
|
||||
- Use `apt search <package>` to find package names
|
||||
- Some packages may require additional configuration after installation
|
||||
@@ -0,0 +1,31 @@
|
||||
from pyinfra.operations import apt
|
||||
from pyinfra import host
|
||||
|
||||
UPDATE = host.data.UPDATE
|
||||
PACKAGES = str(host.data.PACKAGES).split(' ')
|
||||
|
||||
apt.packages(
|
||||
name='Install essential packages',
|
||||
packages=[
|
||||
'htop',
|
||||
'age',
|
||||
'git',
|
||||
'curl',
|
||||
'nano',
|
||||
'wget',
|
||||
'ca-certificates',
|
||||
'ufw',
|
||||
"gnupg",
|
||||
"lsb-release"
|
||||
],
|
||||
update=UPDATE,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name='Install custom packages',
|
||||
packages=[p.strip() for p in PACKAGES if p],
|
||||
update=UPDATE,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'apt:install',
|
||||
name: 'Install packages with apt',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
UPDATE: z.boolean().describe('Update package cache before installing').default(false),
|
||||
PACKAGES: z
|
||||
.string()
|
||||
.describe('Space-separated list of packages to install')
|
||||
.default('vim htop curl'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
# armor-fail2ban
|
||||
|
||||
**Install and enable fail2ban**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube installs and configures Fail2ban, an intrusion prevention software that protects your server from brute-force attacks and unauthorized access attempts.
|
||||
|
||||
## What is Fail2ban?
|
||||
|
||||
Fail2ban monitors log files (e.g., `/var/log/auth.log`) for suspicious activity, such as repeated failed login attempts. When it detects malicious behavior patterns, it automatically:
|
||||
|
||||
- Bans the offending IP address by updating firewall rules
|
||||
- Prevents the attacker from making further connection attempts
|
||||
- Can send email notifications about bans (if configured)
|
||||
|
||||
Common use cases include:
|
||||
- Protecting SSH from brute-force password attacks
|
||||
- Blocking repeated failed login attempts on web applications
|
||||
- Preventing DoS attacks from specific IP addresses
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Installs the `fail2ban` package via apt
|
||||
2. Deploys a custom configuration file (`jail.local`) to `/etc/fail2ban/jail.local`
|
||||
3. Configures fail2ban with sensible defaults for common services
|
||||
|
||||
## Configuration
|
||||
|
||||
This cube currently has no configurable parameters. The default configuration is applied from the included `jail.local` file.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Notes
|
||||
|
||||
After deployment, you can:
|
||||
- Check fail2ban status: `sudo fail2ban-client status`
|
||||
- View banned IPs: `sudo fail2ban-client status sshd`
|
||||
- Unban an IP: `sudo fail2ban-client set sshd unbanip <IP_ADDRESS>`
|
||||
@@ -0,0 +1,18 @@
|
||||
from pyinfra.operations import apt, files, server
|
||||
|
||||
# Install Fail2ban
|
||||
apt.packages(
|
||||
name='Install Fail2ban',
|
||||
packages=['fail2ban'],
|
||||
update=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Configure Fail2ban
|
||||
files.put(
|
||||
name='Configure Fail2ban',
|
||||
src='jail.local',
|
||||
dest='/etc/fail2ban/jail.local',
|
||||
mode='0644',
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
filter = sshd
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 3
|
||||
findtime = 600
|
||||
bantime = 3600
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
|
||||
export default Manifest({
|
||||
id: 'armor:fail2ban',
|
||||
name: 'Install and enable fail2ban',
|
||||
dependencies: () => [],
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
# armor-ssh
|
||||
|
||||
**Secure SSH server by disabling password authentication**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube hardens your SSH server configuration by disabling less secure authentication methods, enforcing SSH key-based authentication only.
|
||||
|
||||
## Why Disable Password Authentication?
|
||||
|
||||
Password-based SSH authentication is vulnerable to:
|
||||
- **Brute-force attacks**: Automated scripts trying millions of password combinations
|
||||
- **Dictionary attacks**: Guessing common passwords
|
||||
- **Credential stuffing**: Using leaked passwords from other breaches
|
||||
- **Weak passwords**: Users choosing easily guessable passwords
|
||||
|
||||
**SSH key authentication is more secure** because:
|
||||
- Keys are cryptographically strong (2048+ bit keys vs 8-12 character passwords)
|
||||
- Private keys never travel over the network
|
||||
- Immune to brute-force attacks
|
||||
- Can be protected with passphrases for additional security
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Disables challenge-response authentication**
|
||||
- Prevents keyboard-interactive authentication prompts
|
||||
|
||||
2. **Optionally disables password authentication** (default: enabled)
|
||||
- Forces users to authenticate with SSH keys only
|
||||
- Prevents password-based login attempts
|
||||
|
||||
3. **Optionally disables PAM** (Pluggable Authentication Modules)
|
||||
- Disables PAM-based authentication methods
|
||||
- Reduces attack surface
|
||||
|
||||
4. **Restarts SSH service**
|
||||
- Applies the new configuration immediately
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **DISABLE_PASSWORD** (boolean, default: `true`)
|
||||
- Disable password authentication for SSH connections
|
||||
- ⚠️ **WARNING**: Ensure you have SSH key access configured before enabling this!
|
||||
|
||||
- **DISABLE_PAM** (boolean, default: `true`)
|
||||
- Disable PAM (Pluggable Authentication Modules) for SSH
|
||||
- Recommended for key-only authentication setups
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**Before deploying this cube**:
|
||||
1. Ensure you have SSH key authentication set up and tested
|
||||
2. Keep an alternative access method available (console access, VNC, etc.)
|
||||
3. Test SSH key login before disabling passwords
|
||||
4. Consider using the `user-add` or `ssh-keyman` cubes first
|
||||
|
||||
**After deployment**:
|
||||
- Only SSH key authentication will work
|
||||
- Password login attempts will be rejected
|
||||
- Make sure to back up your private SSH key securely
|
||||
|
||||
## Post-Installation
|
||||
|
||||
The SSH service will restart automatically. Your current SSH session will remain active, but new connections must use SSH keys.
|
||||
|
||||
To verify the configuration:
|
||||
```bash
|
||||
sudo grep -E "PasswordAuthentication|ChallengeResponseAuthentication|UsePAM" /etc/ssh/sshd_config
|
||||
```
|
||||
|
||||
## Recovery
|
||||
|
||||
If you get locked out:
|
||||
1. Access the server via console (physical or cloud provider's web console)
|
||||
2. Edit `/etc/ssh/sshd_config`
|
||||
3. Set `PasswordAuthentication yes`
|
||||
4. Restart SSH: `sudo systemctl restart ssh`
|
||||
@@ -0,0 +1,43 @@
|
||||
from pyinfra.operations import files, server
|
||||
from pyinfra import host
|
||||
from pyinfra import config
|
||||
import logging
|
||||
|
||||
DISABLE_PASSWORD=host.data.DISABLE_PASSWORD
|
||||
DISABLE_PAM=host.data.DISABLE_PAM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config.SUDO = True
|
||||
|
||||
files.line(
|
||||
name='Disable challenge-response authentication in SSH',
|
||||
path='/etc/ssh/sshd_config',
|
||||
line='ChallengeResponseAuthentication yes',
|
||||
replace='ChallengeResponseAuthentication no',
|
||||
)
|
||||
|
||||
if DISABLE_PASSWORD:
|
||||
files.line(
|
||||
name='Disable password authentication in SSH',
|
||||
path='/etc/ssh/sshd_config',
|
||||
line='PasswordAuthentication yes',
|
||||
replace='PasswordAuthentication no',
|
||||
)
|
||||
else:
|
||||
logger.info('Password authentication allowed')
|
||||
|
||||
if DISABLE_PAM:
|
||||
files.line(
|
||||
name='Disable PAM in SSH',
|
||||
path='/etc/ssh/sshd_config',
|
||||
line='UsePAM yes',
|
||||
replace='UsePAM no',
|
||||
)
|
||||
|
||||
# Restart SSH service
|
||||
server.service(
|
||||
'ssh',
|
||||
running=True,
|
||||
restarted=True,
|
||||
reloaded=True
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'armor:ssh',
|
||||
name: 'Secure SSH server by disabling password authentication',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
DISABLE_PASSWORD: z
|
||||
.boolean()
|
||||
.describe('Disable password authentication for SSH connections')
|
||||
.default(true),
|
||||
DISABLE_PAM: z
|
||||
.boolean()
|
||||
.describe('Disable PAM (Pluggable Authentication Modules) for SSH')
|
||||
.default(true),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
# armor-ufw
|
||||
|
||||
**Activate UFW (Uncomplicated Firewall)**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube configures and enables UFW, a user-friendly firewall management tool for Linux systems, providing basic protection against unauthorized network access.
|
||||
|
||||
## What is UFW?
|
||||
|
||||
UFW (Uncomplicated Firewall) is a frontend for `iptables` designed to make firewall configuration simple and accessible. It provides:
|
||||
|
||||
- **Easy-to-understand syntax**: Commands like `ufw allow ssh` instead of complex iptables rules
|
||||
- **Default deny policy**: Blocks all incoming connections except those explicitly allowed
|
||||
- **Connection tracking**: Automatically handles related and established connections
|
||||
- **Application profiles**: Pre-configured rules for common services
|
||||
|
||||
Think of UFW as a security gate for your server - it controls which network traffic is allowed in and out.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Configures UFW to allow SSH connections (port 22)
|
||||
- Ensures you don't lock yourself out when enabling the firewall
|
||||
2. Optionally allows HTTP traffic (port 80) based on the `ALLOW_HTTP` parameter
|
||||
3. Enables the firewall with the configured rules
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **ALLOW_HTTP** (boolean, default: `true`)
|
||||
- Allow incoming HTTP traffic on port 80
|
||||
- Set to `false` if you're only using HTTPS or don't need web traffic
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **apt:essentials** - Required for basic system tools
|
||||
|
||||
## Security Notes
|
||||
|
||||
**Important**: This cube automatically allows SSH to prevent lockouts. If you need to allow additional services, you can run:
|
||||
|
||||
```bash
|
||||
sudo ufw allow [port number]/[protocol]
|
||||
sudo ufw allow [service-name]
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `sudo ufw allow 443/tcp` - Allow HTTPS
|
||||
- `sudo ufw allow 3000/tcp` - Allow custom application port
|
||||
- `sudo ufw allow https` - Allow HTTPS by service name
|
||||
|
||||
## Post-Installation
|
||||
|
||||
Check firewall status:
|
||||
```bash
|
||||
sudo ufw status verbose
|
||||
sudo ufw status numbered
|
||||
```
|
||||
|
||||
Common UFW commands:
|
||||
- Delete rule: `sudo ufw delete [rule number]`
|
||||
- Disable firewall: `sudo ufw disable`
|
||||
- Reset to defaults: `sudo ufw reset`
|
||||
|
||||
## UFW File Locations
|
||||
|
||||
UFW rules are stored in the `/etc/ufw` directory:
|
||||
|
||||
- `/etc/ufw/user.rules` - Custom rules added via the `ufw` command
|
||||
- `/etc/ufw/before.rules` - Rules processed before user rules (high priority)
|
||||
- `/etc/ufw/after.rules` - Rules processed after user rules (exceptions)
|
||||
- `/etc/ufw/sysctl.conf` - Kernel network parameters (e.g., packet forwarding)
|
||||
- `/etc/ufw/applications.d/` - Application profiles for common services
|
||||
- `/etc/default/ufw` - Global UFW settings and default policies
|
||||
|
||||
Understanding these locations is helpful for troubleshooting, manual edits, or backing up your firewall configuration.
|
||||
@@ -0,0 +1,13 @@
|
||||
from pyinfra.operations import server
|
||||
from pyinfra import host
|
||||
|
||||
ALLOW_HTTP=host.data.ALLOW_HTTP
|
||||
|
||||
server.shell(
|
||||
commands=[
|
||||
f"ufw allow ssh",
|
||||
f"ufw allow http" if ALLOW_HTTP else "",
|
||||
f"ufw enable",
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'armor:ufw',
|
||||
name: 'Activate ufw (uncomplicated firewall)',
|
||||
dependencies: () => ['apt:essentials'],
|
||||
schema: z.object({
|
||||
ALLOW_HTTP: z.boolean().describe('Allow incoming HTTP traffic on port 80').default(true),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
# caddy
|
||||
|
||||
**Install Caddy webserver**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube installs Caddy, a modern, powerful web server with automatic HTTPS that's designed to be easy to use and configure.
|
||||
|
||||
## What is Caddy?
|
||||
|
||||
Caddy is a next-generation web server that stands out for its simplicity and built-in security:
|
||||
|
||||
- **Automatic HTTPS**: Automatically obtains and renews SSL/TLS certificates from Let's Encrypt
|
||||
- **Modern HTTP features**: HTTP/2, HTTP/3 (QUIC) support out of the box
|
||||
- **Simple configuration**: Human-readable Caddyfile format
|
||||
- **Reverse proxy**: Easy proxying to backend applications
|
||||
- **Static file serving**: Fast and efficient static site hosting
|
||||
- **Zero-downtime reloads**: Update config without dropping connections
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Adds Caddy's official repository**
|
||||
- Installs required dependencies (debian-keyring, apt-transport-https, curl)
|
||||
- Downloads and installs Caddy's GPG signing key
|
||||
- Configures APT to use Caddy's official stable repository
|
||||
|
||||
2. **Installs Caddy**
|
||||
- Installs the latest stable version of Caddy
|
||||
- Sets up the Caddy service
|
||||
|
||||
3. **Configures TLS**
|
||||
- Creates a Caddyfile with a reusable TLS snippet
|
||||
- Configures TLS based on the `TLS` parameter
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **TLS** (string, default: `''`)
|
||||
- TLS certificate configuration
|
||||
- **Options**:
|
||||
- `''` (empty string) - Automatic HTTPS with Let's Encrypt (recommended)
|
||||
- `'internal'` - Use Caddy's internal CA for self-signed certs (testing only)
|
||||
- `'/path/to/cert /path/to/key'` - Provide custom certificate paths
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## TLS Configuration Examples
|
||||
|
||||
**Automatic HTTPS (Production)**:
|
||||
```javascript
|
||||
exec('caddy', { TLS: '' })
|
||||
```
|
||||
Caddy will automatically obtain SSL certificates from Let's Encrypt for your domain.
|
||||
|
||||
**Self-Signed for Testing**:
|
||||
```javascript
|
||||
exec('caddy', { TLS: 'internal' })
|
||||
```
|
||||
Uses Caddy's internal CA. Browsers will show security warnings.
|
||||
|
||||
**Custom Certificates**:
|
||||
```javascript
|
||||
exec('caddy', { TLS: '/etc/ssl/certs/mycert.pem /etc/ssl/private/mykey.pem' })
|
||||
```
|
||||
Use your own certificate and private key files.
|
||||
|
||||
## Post-Installation
|
||||
|
||||
The Caddyfile is created at `/etc/caddy/Caddyfile` with a reusable TLS snippet:
|
||||
|
||||
```
|
||||
(tls_cert) {
|
||||
tls {TLS}
|
||||
}
|
||||
```
|
||||
|
||||
Other cubes (like `caddy-spa`) can import this snippet with `import tls_cert`.
|
||||
|
||||
## Managing Caddy
|
||||
|
||||
Start/stop/restart Caddy:
|
||||
```bash
|
||||
sudo systemctl start caddy
|
||||
sudo systemctl stop caddy
|
||||
sudo systemctl restart caddy
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
Reload configuration without downtime:
|
||||
```bash
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Test configuration:
|
||||
```bash
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- Reverse proxy for Node.js/Python/Go apps
|
||||
- Static website hosting
|
||||
- API gateway
|
||||
- Load balancer
|
||||
- SSL/TLS termination
|
||||
|
||||
## Notes
|
||||
|
||||
- Caddy runs on ports 80 (HTTP) and 443 (HTTPS) by default
|
||||
- Ensure these ports are open in your firewall (UFW)
|
||||
- For automatic HTTPS, your domain must point to your server's IP
|
||||
- Caddy automatically redirects HTTP to HTTPS when using automatic HTTPS
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Caddy Documentation](https://caddyserver.com/docs/)
|
||||
- [Caddyfile Tutorial](https://caddyserver.com/docs/caddyfile-tutorial)
|
||||
@@ -0,0 +1,34 @@
|
||||
from pyinfra.operations import apt, server, files
|
||||
from pyinfra import host
|
||||
from io import StringIO
|
||||
|
||||
# 🔹 Variables
|
||||
TLS = host.data.TLS
|
||||
|
||||
server.shell(
|
||||
commands=[
|
||||
f"sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl",
|
||||
f"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
|
||||
f"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list"
|
||||
]
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name='Install caddy',
|
||||
packages=['caddy'],
|
||||
update=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
|
||||
TLS_BLOCK = f"""
|
||||
(tls_cert) {{
|
||||
tls {TLS}
|
||||
}}
|
||||
"""
|
||||
|
||||
files.put(
|
||||
src = StringIO(TLS_BLOCK), # local filename to upload,
|
||||
dest = '/etc/caddy/Caddyfile', # the remote filename to upload to
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'caddy',
|
||||
name: 'Install Caddy webserver',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
// tls /path/to/cert.pem /path/to/key.pem
|
||||
TLS: z
|
||||
.string()
|
||||
.default('')
|
||||
.describe(`
|
||||
<empty-string> --> enabled and automatically managed
|
||||
internal --> use Caddy custom root CA for self-signed certs (for testing purpose)
|
||||
/path/to/cert /path/to/key --> Provide custom certificates
|
||||
`),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
# caddy-spa
|
||||
|
||||
**Configure Caddy to serve a Single Page Application**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube adds a reverse proxy configuration to Caddy for serving a Single Page Application (SPA) from a local backend server, with automatic HTTPS support.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Adds domain configuration to Caddyfile**
|
||||
- Creates a site block for the specified domain
|
||||
- Configures reverse proxy to forward traffic to local application
|
||||
- Imports TLS configuration from the `caddy` cube
|
||||
|
||||
2. **Configures reverse proxy**
|
||||
- Proxies all requests to `localhost:{PORT}`
|
||||
- Preserves headers and client information
|
||||
- Handles WebSocket connections
|
||||
|
||||
3. **Restarts Caddy service**
|
||||
- Applies the new configuration immediately
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **DOMAIN** (string, default: `''`)
|
||||
- Domain name for the SPA application
|
||||
- Example: `'myapp.example.com'`
|
||||
- Must have DNS pointing to your server's IP
|
||||
|
||||
- **PORT** (number, default: `5432`)
|
||||
- Port number where the SPA will be served
|
||||
- Your application should be listening on this port locally
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **caddy** cube (implicitly required) - Must be installed first to provide the `tls_cert` snippet
|
||||
|
||||
## What Gets Configured
|
||||
|
||||
This cube adds the following to `/etc/caddy/Caddyfile`:
|
||||
|
||||
```
|
||||
# BEGIN DOMAIN myapp.example.com
|
||||
myapp.example.com {
|
||||
import tls_cert
|
||||
reverse_proxy localhost:5432
|
||||
}
|
||||
# END myapp.example.com
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
**Deploy a React/Vue/Angular app**:
|
||||
```javascript
|
||||
exec('caddy-spa', {
|
||||
DOMAIN: 'app.example.com',
|
||||
PORT: 3000
|
||||
})
|
||||
```
|
||||
|
||||
**Deploy multiple SPAs**:
|
||||
```javascript
|
||||
exec('caddy-spa', { DOMAIN: 'app1.example.com', PORT: 3000 })
|
||||
exec('caddy-spa', { DOMAIN: 'app2.example.com', PORT: 3001 })
|
||||
exec('caddy-spa', { DOMAIN: 'app3.example.com', PORT: 3002 })
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User visits `https://myapp.example.com`
|
||||
2. Caddy receives the request on port 443 (HTTPS)
|
||||
3. Caddy automatically handles SSL/TLS encryption
|
||||
4. Request is forwarded to `localhost:5432`
|
||||
5. Your application receives the request and returns a response
|
||||
6. Caddy sends the encrypted response back to the user
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before deploying this cube:
|
||||
|
||||
1. **Install the caddy cube first**
|
||||
- Provides the base Caddy installation and TLS configuration
|
||||
|
||||
2. **Ensure your application is running**
|
||||
- Your SPA backend should be listening on the specified PORT
|
||||
- Example: `npm start` or `pm2 start app.js`
|
||||
|
||||
3. **Configure DNS**
|
||||
- Point your domain's A record to your server's IP address
|
||||
- Wait for DNS propagation (can take a few minutes to hours)
|
||||
|
||||
4. **Open firewall ports**
|
||||
- Ensure ports 80 and 443 are open (for automatic HTTPS)
|
||||
- `sudo ufw allow 80/tcp`
|
||||
- `sudo ufw allow 443/tcp`
|
||||
|
||||
## Post-Installation
|
||||
|
||||
Verify the configuration:
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
Check Caddy status:
|
||||
```bash
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
View Caddy logs:
|
||||
```bash
|
||||
sudo journalctl -u caddy -f
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
**502 Bad Gateway**:
|
||||
- Your application isn't running on the specified PORT
|
||||
- Check: `netstat -tlnp | grep {PORT}`
|
||||
|
||||
**Certificate errors**:
|
||||
- DNS not pointing to your server
|
||||
- Ports 80/443 blocked by firewall
|
||||
- Check Caddy logs: `sudo journalctl -u caddy -f`
|
||||
|
||||
**Domain not resolving**:
|
||||
- DNS propagation not complete yet
|
||||
- Verify with: `dig +short myapp.example.com`
|
||||
|
||||
## Notes
|
||||
|
||||
- Caddy automatically obtains and renews Let's Encrypt certificates
|
||||
- The reverse proxy preserves the original client IP and headers
|
||||
- WebSocket connections are automatically supported
|
||||
- You can add multiple domains by running this cube multiple times with different parameters
|
||||
@@ -0,0 +1,32 @@
|
||||
from pyinfra.operations import apt, server, files
|
||||
from pyinfra import host
|
||||
from io import StringIO
|
||||
|
||||
# 🔹 Variables (Modify as Needed)
|
||||
DOMAIN = host.data.DOMAIN
|
||||
PORT = host.data.PORT
|
||||
|
||||
|
||||
site_block = f"""# BEGIN DOMAIN {DOMAIN}
|
||||
{DOMAIN} {{
|
||||
import tls_cert
|
||||
reverse_proxy localhost:{PORT}
|
||||
}}
|
||||
# END {DOMAIN}
|
||||
"""
|
||||
|
||||
files.block(
|
||||
path = '/etc/caddy/Caddyfile',
|
||||
content = site_block,
|
||||
present = True,
|
||||
before = False,
|
||||
after = False,
|
||||
_sudo = True
|
||||
)
|
||||
|
||||
server.service(
|
||||
service='caddy',
|
||||
running=True,
|
||||
restarted=True,
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'caddy:spa',
|
||||
name: 'Install single page application',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
DOMAIN: z.string().describe('Domain name for the SPA application').default(''),
|
||||
PORT: z.number().describe('Port number where the SPA will be served').default(5432),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
from pyinfra.operations import server
|
||||
from pyinfra import host
|
||||
|
||||
USER = host.data.USER
|
||||
REPO = host.data.REPO
|
||||
APP = host.data.APP
|
||||
|
||||
server.shell(
|
||||
name="Clone application",
|
||||
commands=[
|
||||
f"""
|
||||
cd $HOME &&
|
||||
git clone --recurse-submodules {REPO} {APP}
|
||||
"""],
|
||||
_sudo=True,
|
||||
_sudo_user=USER,
|
||||
_use_sudo_login=True
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'git:clone',
|
||||
name: 'Clone a repository',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
USER: z.string().describe('Username for which to clone the repository').default('vagrant'),
|
||||
REPO: z.string().default(''),
|
||||
APP: z.string().describe('The internal name used for this application').default(''),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
# Tailscale Cube
|
||||
|
||||
Installs and authenticates the Tailscale client on a Linux host.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automated Installation**: Adds the official Tailscale repository and installs the package.
|
||||
- **Headless Authentication**: Uses a Tailscale Auth Key for zero-interaction setup.
|
||||
- **Headscale Support**: Can be configured to connect to a custom login server.
|
||||
- **Startup persistence**: Ensures the `tailscaled` daemon is enabled and running.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `AUTH_KEY` | `""` | **Secret.** Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). |
|
||||
| `LOGIN_SERVER` | `https://controlplane.tailscale.com` | The coordination server URL. Set this to your Headscale instance URL if applicable. |
|
||||
| `EXTRA_ARGS` | `""` | Additional flags to pass to `tailscale up` (e.g., `--advertise-exit-node`). |
|
||||
| `FORCE_REAUTH` | `false` | If true, forces the client to re-authenticate. |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
nopy install tailscale
|
||||
```
|
||||
|
||||
When prompted, provide your `AUTH_KEY`. If you are using Headscale, also provide the `LOGIN_SERVER` URL.
|
||||
|
||||
`AUTH_KEY` is declared in the manifest's `secrets`, so nopy keeps it out of session
|
||||
and history files and masks it in any command it prints. It is asked for again on
|
||||
replay, and a `--use-defaults` replay refuses rather than joining the tailnet with
|
||||
an empty key. Prefer an ephemeral key regardless — the value is still on pyinfra's
|
||||
command line while the deployment runs.
|
||||
@@ -0,0 +1,45 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server, apt
|
||||
|
||||
# Variables from manifest
|
||||
AUTH_KEY = host.data.AUTH_KEY
|
||||
LOGIN_SERVER = host.data.LOGIN_SERVER
|
||||
EXTRA_ARGS = host.data.EXTRA_ARGS
|
||||
FORCE_REAUTH = host.data.FORCE_REAUTH
|
||||
|
||||
# 1. Install Tailscale using the official one-liner script
|
||||
server.shell(
|
||||
name="Install Tailscale",
|
||||
commands=["curl -fsSL https://tailscale.com/install.sh | sh"],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# 2. Ensure Tailscale is enabled and running
|
||||
# Manually (Linux): sudo systemctl enable --now tailscaled
|
||||
server.service(
|
||||
name="Ensure tailscaled is running and enabled on boot",
|
||||
service="tailscaled",
|
||||
running=True,
|
||||
enabled=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# 3. Authenticate and bring Tailscale up
|
||||
# We use --authkey for headless mode
|
||||
# We use --login-server if it's different from the default
|
||||
up_command = f"tailscale up --authkey {AUTH_KEY}"
|
||||
|
||||
if LOGIN_SERVER and LOGIN_SERVER != "https://controlplane.tailscale.com":
|
||||
up_command += f" --login-server {LOGIN_SERVER}"
|
||||
|
||||
if FORCE_REAUTH:
|
||||
up_command += " --force-reauth"
|
||||
|
||||
if EXTRA_ARGS:
|
||||
up_command += f" {EXTRA_ARGS}"
|
||||
|
||||
server.shell(
|
||||
name="Authenticate Tailscale (Headless)",
|
||||
commands=[up_command],
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'net:tailscale',
|
||||
name: 'Install and authenticate Tailscale',
|
||||
dependencies: () => ['apt:essentials'],
|
||||
secrets: ['AUTH_KEY'],
|
||||
schema: z.object({
|
||||
AUTH_KEY: z.string().describe('Tailscale Auth Key for headless authentication').default(''),
|
||||
LOGIN_SERVER: z
|
||||
.string()
|
||||
.describe('Custom login server (e.g., for Headscale)')
|
||||
.default('https://controlplane.tailscale.com'),
|
||||
EXTRA_ARGS: z.string().describe('Additional arguments for tailscale up').default(''),
|
||||
FORCE_REAUTH: z.boolean().describe('Force re-authentication').default(false),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
# WiFi Access Point Cube (NetworkManager)
|
||||
|
||||
Configures a Linux device as a WiFi Access Point using NetworkManager's `nmcli` command. **This approach allows the device to simultaneously act as an AP AND remain connected to another WiFi network as a client.**
|
||||
|
||||
## Key Features
|
||||
|
||||
- ✅ **Dual WiFi Mode**: Acts as AP while staying connected to another WiFi
|
||||
- ✅ **Modern Approach**: Uses NetworkManager (nmcli) instead of hostapd
|
||||
- ✅ **Auto-start**: Configured to start on boot
|
||||
- ✅ **IP Sharing**: Built-in internet sharing with `ipv4.method shared`
|
||||
- ✅ **Simple Configuration**: Minimal parameters, maximum functionality
|
||||
- ✅ **Supports 2.4GHz and 5GHz bands**
|
||||
|
||||
## Requirements
|
||||
|
||||
- Device with WiFi capability (e.g., Raspberry Pi 4/5)
|
||||
- NetworkManager installed and running
|
||||
- WiFi hardware that supports AP mode
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
> **This section is out of date** — it lists parameters the manifest does not
|
||||
> declare (`NETWORK_DEVICE`, `CHANNEL`, `IP_ADDRESS`) and omits `AP_IP`. Read
|
||||
> `manifest.mjs` for the real list. Tracked as §5 in the repository's
|
||||
> `DOCS-AUDIT.md`.
|
||||
|
||||
### Prompted first
|
||||
|
||||
- **SSID**: WiFi network name (1-32 characters). Defaults to `PiPoint`.
|
||||
- **PASSWORD**: WPA2 password (8-63 characters). Defaults to `1223334444` — a
|
||||
placeholder that should not survive contact with a real network.
|
||||
Declared in the manifest's `secrets`, so nopy keeps it out of session and
|
||||
history files and masks it in printed commands, and re-prompts on replay. The
|
||||
value is still on pyinfra's command line, so it is visible in `ps` during the run.
|
||||
|
||||
### Optional (with defaults)
|
||||
|
||||
- **NETWORK_DEVICE**: WiFi interface to use (`wlan0` default)
|
||||
- **BAND**: Frequency band - `2.4GHz` or `5GHz` (auto-detected from current connection if not specified)
|
||||
- **CHANNEL**: WiFi channel (auto-detected from current connection if not specified)
|
||||
- 2.4GHz: 1-14
|
||||
- 5GHz: 36-165
|
||||
- **IP_ADDRESS**: AP gateway address (`192.168.50.1` default)
|
||||
- **CONNECTION_NAME**: NetworkManager connection name (`net:wifi:ap` default)
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"SSID": "MyHomeWiFi",
|
||||
"PASSWORD": "SecurePass123"
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Configuration (5GHz with specific channel)
|
||||
|
||||
```json
|
||||
{
|
||||
"SSID": "FastWiFi5G",
|
||||
"PASSWORD": "SuperSecure456",
|
||||
"NETWORK_DEVICE": "wlan1",
|
||||
"BAND": "5GHz",
|
||||
"CHANNEL": 36,
|
||||
"IP_ADDRESS": "10.0.0.1",
|
||||
"CONNECTION_NAME": "my-hotspot"
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Detection Configuration
|
||||
|
||||
When `BAND` and `CHANNEL` are not specified, the script will automatically detect the current AP's band and channel using `iw dev <interface> link` and use those values for the new hotspot. This is useful when you want the hotspot to operate on the same band/channel as your existing connection to avoid interference.
|
||||
|
||||
```json
|
||||
{
|
||||
"SSID": "MyAutoAP",
|
||||
"PASSWORD": "SecurePass123"
|
||||
}
|
||||
```
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Installs NetworkManager** (if not present)
|
||||
2. **Enables NetworkManager service** and ensures it's running
|
||||
3. **Auto-detects current AP settings** using `iw dev <interface> link` to find the band and channel
|
||||
4. **Creates WiFi hotspot** using `nmcli` with auto-detected or specified settings
|
||||
5. **Configures IP sharing** with `ipv4.method shared` (automatic DHCP + NAT)
|
||||
6. **Enables IP forwarding** for internet routing
|
||||
7. **Sets autoconnect** so the AP starts on boot
|
||||
|
||||
## How It Works (Dual WiFi Mode)
|
||||
|
||||
### Simultaneous AP + Client Mode
|
||||
|
||||
**Hardware Support Required:**
|
||||
|
||||
- Your WiFi hardware must support **simultaneous AP+STA (Station) mode**
|
||||
- Most modern WiFi chips support this (e.g., Raspberry Pi 4/5, Intel WiFi cards)
|
||||
- Older hardware may not support it (e.g., Raspberry Pi 3B and earlier have limitations)
|
||||
|
||||
**How NetworkManager Handles It:**
|
||||
|
||||
1. **Single Interface (e.g., wlan0):**
|
||||
- If hardware supports AP+STA: ✅ **Both client and AP run on same interface**
|
||||
- If hardware doesn't support it: ⚠️ **Client connection may be dropped**
|
||||
|
||||
2. **Multiple Interfaces (e.g., wlan0 + wlan1):**
|
||||
- NetworkManager will use one for client, another for AP
|
||||
- Always works regardless of hardware capabilities
|
||||
|
||||
**Example Scenario (Raspberry Pi 4):**
|
||||
|
||||
```
|
||||
[Internet] <--WiFi--> [RPi4 wlan0 (Client+AP)] <--WiFi--> [Devices connect to AP]
|
||||
```
|
||||
|
||||
**How to Check Hardware Support:**
|
||||
|
||||
```bash
|
||||
iw list | grep -A 10 "valid interface combinations"
|
||||
```
|
||||
|
||||
Look for: `* #{ managed } <= 1, #{ AP } <= 1` or `* #{ managed, AP } <= 2`
|
||||
|
||||
NetworkManager will:
|
||||
|
||||
- Share internet from **any** available source (WiFi client, Ethernet, cellular, etc.)
|
||||
- Automatically handle routing and NAT
|
||||
- Try to maintain client connection if hardware supports it
|
||||
|
||||
## Post-Installation
|
||||
|
||||
After deployment, the device will:
|
||||
|
||||
- ✅ Broadcast the WiFi network with your SSID
|
||||
- ✅ Accept connections with your password
|
||||
- ✅ Assign IP addresses to connected clients (via built-in DHCP)
|
||||
- ✅ Share internet connection from any available interface
|
||||
- ✅ Auto-start the AP on every boot
|
||||
- ✅ Maintain client WiFi connection (if connected to another network)
|
||||
|
||||
## Managing the Hotspot
|
||||
|
||||
### View connection status
|
||||
|
||||
```bash
|
||||
nmcli connection show
|
||||
```
|
||||
|
||||
### Stop the hotspot
|
||||
|
||||
```bash
|
||||
sudo nmcli connection down net:wifi:ap
|
||||
```
|
||||
|
||||
### Start the hotspot
|
||||
|
||||
```bash
|
||||
sudo nmcli connection up net:wifi:ap
|
||||
```
|
||||
|
||||
### Disable autostart
|
||||
|
||||
```bash
|
||||
sudo nmcli connection modify net:wifi:ap connection.autoconnect no
|
||||
```
|
||||
|
||||
### Delete the hotspot
|
||||
|
||||
```bash
|
||||
sudo nmcli connection delete net:wifi:ap
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### AP doesn't start
|
||||
|
||||
- Check NetworkManager status: `sudo systemctl status NetworkManager`
|
||||
- Verify WiFi interface exists: `nmcli device status`
|
||||
- Check if AP mode is supported: `iw list | grep -A 10 "Supported interface modes"`
|
||||
|
||||
### Can't connect to AP
|
||||
|
||||
- Verify password is correct (8+ characters)
|
||||
- Check channel compatibility with your devices
|
||||
- Try switching between 2.4GHz and 5GHz bands
|
||||
|
||||
### No internet on clients
|
||||
|
||||
- Verify host device has internet: `ping 8.8.8.8`
|
||||
- Check IP forwarding: `sysctl net.ipv4.ip_forward`
|
||||
- NetworkManager should handle NAT automatically with `ipv4.method shared`
|
||||
|
||||
### Hotspot conflicts with client WiFi
|
||||
|
||||
- This shouldn't happen with NetworkManager
|
||||
- If it does, check if hardware supports simultaneous AP+STA mode:
|
||||
|
||||
```bash
|
||||
iw list | grep "valid interface combinations"
|
||||
```
|
||||
|
||||
## Advantages Over Traditional Approach
|
||||
|
||||
| Feature | Traditional (hostapd) | NetworkManager (nmcli) |
|
||||
|---------|----------------------|------------------------|
|
||||
| **Dual WiFi** | ❌ No (conflicts with wpa_supplicant) | ✅ Yes (AP + client simultaneously) |
|
||||
| **Configuration** | Complex (multiple files) | Simple (one command) |
|
||||
| **DHCP** | Manual (dnsmasq) | Automatic |
|
||||
| **NAT** | Manual (iptables) | Automatic |
|
||||
| **Management** | Multiple services | Single service |
|
||||
| **Dependencies** | hostapd, dnsmasq, iptables | NetworkManager only |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always use a strong password (minimum 8 characters)
|
||||
- WPA2 encryption is automatically enabled
|
||||
- NetworkManager handles firewall rules automatically
|
||||
|
||||
## Compatibility
|
||||
|
||||
Tested on:
|
||||
|
||||
- Raspberry Pi 4/5 with built-in WiFi
|
||||
- Ubuntu/Debian-based systems with NetworkManager
|
||||
- Devices with WiFi hardware supporting AP mode
|
||||
|
||||
**Note**: Older Raspberry Pi models (3B and earlier) may have limitations with simultaneous AP + client mode due to hardware constraints.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
|
||||
|
||||
sudo nmcli con add type wifi con-name MyHotspot ifname wlan0 mode ap ssid YourNewAP ipv4.method shared wifi-sec.key-mgmt wpa-psk wifi-sec.psk "YourNewPassword"
|
||||
sudo nmcli con up MyHotspot
|
||||
sudo nmcli con down MyHotspot
|
||||
sudo nmcli con mod MyHotspot wifi.band <band> wifi.channel <channel_number>
|
||||
# Find the currently used band and channel
|
||||
iw dev wlan0 link
|
||||
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
from pyinfra.operations import apt, server, systemd
|
||||
from pyinfra import host
|
||||
|
||||
# Extract variables with proper defaults
|
||||
SSID = host.data.get('SSID')
|
||||
PASSWORD = host.data.get('PASSWORD')
|
||||
BAND = host.data.get('BAND', '2.4GHz')
|
||||
CHANNEL = host.data.get('CHANNEL') # Optional
|
||||
AP_IP = host.data.get('AP_IP')
|
||||
CONNECTION_NAME = host.data.get('CONNECTION_NAME')
|
||||
|
||||
# Determine band configuration
|
||||
if BAND == '5GHz':
|
||||
BAND = 'a'
|
||||
default_channel = 36 if not CHANNEL else CHANNEL
|
||||
else: # 2.4GHz
|
||||
BAND = 'bg'
|
||||
default_channel = 6 if not CHANNEL else CHANNEL
|
||||
|
||||
# Install NetworkManager if not present
|
||||
apt.packages(
|
||||
name='Install NetworkManager',
|
||||
packages=['network-manager'],
|
||||
update=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Ensure NetworkManager is running
|
||||
systemd.service(
|
||||
name='Ensure NetworkManager is running',
|
||||
service='NetworkManager',
|
||||
running=True,
|
||||
enabled=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Check if connection already exists and delete it
|
||||
server.shell(
|
||||
name='Delete existing AP connection if present',
|
||||
commands=[f'nmcli connection delete {CONNECTION_NAME} || true'],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Add Wifi AP connection
|
||||
server.shell(
|
||||
name='Detect WiFi interface',
|
||||
commands=[
|
||||
f'sudo nmcli con add type wifi con-name {CONNECTION_NAME} ifname wlan0 mode ap ssid {SSID} ipv4.method shared wifi-sec.key-mgmt wpa-psk wifi-sec.psk "{PASSWORD}"',
|
||||
# CRITICAL: Force WPA2 (rsn) instead of WPA (wpa)
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.proto rsn',
|
||||
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} ipv4.addresses {AP_IP}/24',
|
||||
|
||||
# Use AES encryption (CCMP) instead of TKIP
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.pairwise ccmp',
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.group ccmp',
|
||||
|
||||
# Set band
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} 802-11-wireless.band {BAND}',
|
||||
|
||||
# Enable autostart
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} connection.autoconnect yes',
|
||||
f'sudo nmcli connection modify {CONNECTION_NAME} connection.autoconnect-priority 10',
|
||||
|
||||
# Start the AP
|
||||
f'sudo nmcli connection up {CONNECTION_NAME}'
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'net:wifi:access-point',
|
||||
name: 'Configure WiFi Access Point (NetworkManager)',
|
||||
dependencies: () => [],
|
||||
secrets: ['PASSWORD'],
|
||||
schema: z.object({
|
||||
SSID: z.string().min(1).max(32).default('PiPoint').describe('WiFi network name (SSID)'),
|
||||
PASSWORD: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(63)
|
||||
.default('1223334444')
|
||||
.describe('WPA2 password (8-63 characters)'),
|
||||
BAND: z.enum(['2.4GHz', '5GHz']).default('2.4GHz').describe('Frequency band'),
|
||||
AP_IP: z.string().default('192.168.4.1').describe('AP IP address'),
|
||||
CONNECTION_NAME: z.string().default('pi-point').describe('NetworkManager connection name'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
# network:wifi:connection
|
||||
|
||||
**Configure a WiFi client connection using NetworkManager**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube allows you to connect your target host (e.g., a Raspberry Pi or a laptop) to an existing WiFi network using `nmcli`.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Ensures `network-manager` is installed and running
|
||||
2. Removes any existing connection with the same name to avoid conflicts
|
||||
3. Connects to the specified `SSID` using the provided `PASSWORD`
|
||||
4. Configures the connection to automatically connect on boot (optional)
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Type | Description | Required | Default |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `SSID` | `string` | The WiFi network name | Yes | - |
|
||||
| `PASSWORD` | `string` | The WiFi password | Yes | - |
|
||||
| `AUTOCONNECT` | `boolean` | Automatically connect to this network | No | `true` |
|
||||
| `CONNECTION_NAME` | `string` | Name for the connection in NetworkManager | No | `SSID` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `apt/essentials`: Basic system utilities.
|
||||
|
||||
## Usage
|
||||
|
||||
### Simple Connection
|
||||
|
||||
```bash
|
||||
nopy install network:wifi:connection --env SSID="MyHomeWiFi" --env PASSWORD="mysecurepassword"
|
||||
```
|
||||
|
||||
### Connection with Custom Name and No Autoconnect
|
||||
|
||||
```bash
|
||||
nopy install network:wifi:connection --env SSID="OfficeWiFi" --env PASSWORD="password123" --env CONNECTION_NAME="Work" --env AUTOCONNECT=false
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- `PASSWORD` is declared in the manifest's `secrets`: nopy keeps it out of session
|
||||
and history files and masks it in every command it prints. It is prompted for
|
||||
again on replay.
|
||||
- That covers what nopy writes, not everything. The value is still on pyinfra's
|
||||
command line, so it is visible in `ps` while the deployment runs.
|
||||
- WiFi passwords will be stored in `/etc/NetworkManager/system-connections/` on the target host.
|
||||
- Passing passwords via `--env` may leave them in your local shell history.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
You can check the status of your WiFi connections on the target host with:
|
||||
```bash
|
||||
nmcli connection show
|
||||
nmcli device status
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, server, systemd
|
||||
|
||||
# [agnt://cogen/cogen/network-wifi-connection-2]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||
|
||||
"""
|
||||
Deployment script for network:wifi:connection.
|
||||
Uses nmcli to configure a WiFi client connection.
|
||||
"""
|
||||
|
||||
SSID = host.data.SSID
|
||||
PASSWORD = host.data.PASSWORD
|
||||
AUTOCONNECT = "yes" if host.data.get('AUTOCONNECT', True) else "no"
|
||||
CONNECTION_NAME = host.data.get('CONNECTION_NAME', SSID)
|
||||
|
||||
# Install NetworkManager if not present
|
||||
apt.packages(
|
||||
name='Install NetworkManager',
|
||||
packages=['network-manager'],
|
||||
update=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Ensure NetworkManager is running
|
||||
systemd.service(
|
||||
name='Ensure NetworkManager is running',
|
||||
service='NetworkManager',
|
||||
running=True,
|
||||
enabled=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Add/Update the WiFi connection
|
||||
# We delete first to ensure a clean state with the new password/settings
|
||||
server.shell(
|
||||
name=f"Configure WiFi connection for {SSID}",
|
||||
commands=[
|
||||
f'nmcli connection delete "{CONNECTION_NAME}" || true',
|
||||
f'nmcli device wifi connect "{SSID}" password "{PASSWORD}" name "{CONNECTION_NAME}"',
|
||||
f'nmcli connection modify "{CONNECTION_NAME}" connection.autoconnect {AUTOCONNECT}',
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||
|
||||
/**
|
||||
* Manifest for the network:wifi:connection cube.
|
||||
* Configures a WiFi client connection using NetworkManager (nmcli).
|
||||
*/
|
||||
export default Manifest({
|
||||
id: 'net:wifi:connection',
|
||||
name: 'network:wifi:connection - Connect to a WiFi network',
|
||||
secrets: ['PASSWORD'],
|
||||
schema: z.object({
|
||||
SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'),
|
||||
PASSWORD: z.string().min(8).describe('The password for the WiFi network'),
|
||||
AUTOCONNECT: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe('Whether to automatically connect to this network'),
|
||||
CONNECTION_NAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional name for the connection (defaults to SSID)'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
# docker
|
||||
|
||||
**Install Docker and tools**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube installs Docker Engine, Docker Compose, and popular Docker management tools to enable containerized application deployment and management.
|
||||
|
||||
## What is Docker?
|
||||
|
||||
Docker is a platform for developing, shipping, and running applications in containers. Containers package an application with all its dependencies, ensuring it runs consistently across different environments.
|
||||
|
||||
Key benefits:
|
||||
- **Isolation**: Each container runs independently with its own filesystem, network, and processes
|
||||
- **Portability**: Containers run the same way on any system that supports Docker
|
||||
- **Efficiency**: Containers share the host OS kernel, making them lighter than virtual machines
|
||||
- **Scalability**: Easily deploy and scale containerized applications
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Adds Docker's official repository**
|
||||
- Downloads and installs Docker's GPG key
|
||||
- Configures APT to use Docker's official package repository
|
||||
|
||||
2. **Installs Docker components**
|
||||
- `docker-ce` - Docker Community Edition engine
|
||||
- `docker-ce-cli` - Command-line interface for Docker
|
||||
- `containerd.io` - Container runtime
|
||||
- `docker-buildx-plugin` - Extended build capabilities with BuildKit
|
||||
- `docker-compose-plugin` - Tool for defining multi-container applications
|
||||
|
||||
3. **Installs management tools**
|
||||
- **lazydocker** - Terminal UI for Docker and Docker Compose management
|
||||
- **docker-ctop** - Container metrics and monitoring (top-like interface for containers)
|
||||
|
||||
## Configuration
|
||||
|
||||
This cube currently has no configurable parameters.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Post-Installation
|
||||
|
||||
After deployment:
|
||||
- Add users to the `docker` group to run Docker without sudo: `sudo usermod -aG docker username`
|
||||
- Start using Docker: `docker run hello-world`
|
||||
- Use lazydocker for easy management: `lazydocker`
|
||||
- Monitor containers: `ctop`
|
||||
|
||||
## Notes
|
||||
|
||||
The Docker daemon starts automatically on boot. You can manage it with systemd:
|
||||
- Check status: `sudo systemctl status docker`
|
||||
- Restart: `sudo systemctl restart docker`
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Installation tutorial Ubuntu 24.04](https://www.cherryservers.com/blog/install-docker-ubuntu)
|
||||
@@ -0,0 +1,75 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server, apt
|
||||
|
||||
|
||||
DISTRO = host.data.DISTRO
|
||||
|
||||
server.shell(
|
||||
commands=[
|
||||
"install -m 0755 -d /etc/apt/keyrings",
|
||||
f"curl -fsSL https://download.docker.com/linux/{DISTRO}/gpg | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg",
|
||||
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
|
||||
f""" echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/{DISTRO} "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null """,
|
||||
"apt update"
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
packages=[
|
||||
"docker-ce",
|
||||
"docker-ce-cli",
|
||||
"containerd.io",
|
||||
"docker-buildx-plugin",
|
||||
"docker-compose-plugin",
|
||||
],
|
||||
present=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
|
||||
server.shell(
|
||||
name="Install Lazydocker system-wide",
|
||||
commands=[
|
||||
'export DIR=/usr/local/bin && curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash -s --'
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Install required packages
|
||||
apt.packages(
|
||||
name="Install required dependencies",
|
||||
packages=["ca-certificates", "curl", "gnupg", "lsb-release"],
|
||||
update=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Download and store the GPG key
|
||||
server.shell(
|
||||
name="Download and store Azlux repo GPG key",
|
||||
commands=[
|
||||
"curl -fsSL https://azlux.fr/repo.gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/azlux-archive-keyring.gpg"
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Add the repository to sources.list
|
||||
server.shell(
|
||||
name="Add Azlux repository to APT sources",
|
||||
commands=[
|
||||
""" echo "deb [arch="$(dpkg --print-architecture)" signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian bookworm main" | sudo tee /etc/apt/sources.list.d/azlux.list > /dev/null """,
|
||||
],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
# Update APT and install docker-ctop
|
||||
apt.update(
|
||||
name="Update package lists",
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Install docker-ctop",
|
||||
packages=["docker-ctop"],
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'runtime:docker',
|
||||
name: 'Install docker and tools',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
DISTRO: z.enum(['ubuntu', 'debian']).default('ubuntu').describe('Linux distribution to target'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
# nodevm
|
||||
|
||||
**Install Node.js with essential global packages**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube installs the latest LTS (Long Term Support) version of Node.js along with essential global npm packages commonly needed for development and deployment.
|
||||
|
||||
## What is Node.js?
|
||||
|
||||
Node.js is a JavaScript runtime built on Chrome's V8 engine that allows you to run JavaScript on the server. It's widely used for:
|
||||
|
||||
- Building web servers and APIs
|
||||
- Command-line tools
|
||||
- Build tools and task runners
|
||||
- Real-time applications (chat, notifications)
|
||||
- Microservices
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Installs Node.js LTS**
|
||||
- Downloads and runs the official NodeSource setup script
|
||||
- Installs the latest LTS version of Node.js
|
||||
- Includes npm (Node Package Manager)
|
||||
|
||||
2. **Installs build dependencies**
|
||||
- `libssl-dev` - SSL/TLS libraries
|
||||
- `libtool` - Library building tools
|
||||
- `cmake` - Cross-platform build system
|
||||
- `libpng-dev`, `libjpeg-dev`, `libvips-dev` - Image processing libraries
|
||||
|
||||
3. **Installs global npm packages**
|
||||
- **npm@11.1.0** - Latest npm version
|
||||
- **pm2** - Production process manager for Node.js apps
|
||||
- **yarn** - Alternative package manager
|
||||
- **local-web-server** - Local development web server
|
||||
- **node-gyp** - Node.js native addon build tool
|
||||
- **inquirer** - Interactive command-line prompts
|
||||
- **execa** - Better child process execution
|
||||
- **@dotenvx/dotenvx** - Environment variable management
|
||||
|
||||
## Configuration
|
||||
|
||||
This cube currently has no configurable parameters.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - this cube can run standalone.
|
||||
|
||||
## Post-Installation
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
Common commands:
|
||||
- Run a Node.js app: `node app.js`
|
||||
- Start with PM2: `pm2 start app.js`
|
||||
- Install packages: `npm install <package>`
|
||||
- Use yarn: `yarn add <package>`
|
||||
|
||||
## PM2 - Process Manager
|
||||
|
||||
PM2 is included for production deployments. Common PM2 commands:
|
||||
|
||||
```bash
|
||||
pm2 start app.js # Start application
|
||||
pm2 list # List running apps
|
||||
pm2 stop app # Stop application
|
||||
pm2 restart app # Restart application
|
||||
pm2 logs # View logs
|
||||
pm2 startup # Enable PM2 on boot
|
||||
pm2 save # Save current process list
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Node.js is installed system-wide
|
||||
- Global packages are accessible to all users
|
||||
- npm cache is stored in `~/.npm`
|
||||
- Use `nvm` if you need multiple Node.js versions
|
||||
@@ -0,0 +1,58 @@
|
||||
from pyinfra.operations import server, apt, npm, python
|
||||
from pyinfra import host
|
||||
from pyinfra.facts.files import Directory
|
||||
from pyinfra.facts.server import Which
|
||||
|
||||
hasNode = host.get_fact(Which, 'node')
|
||||
VERSION = host.data.VERSION
|
||||
ALIAS = host.data.ALIAS
|
||||
GLOBAL_PACKAGES = host.data.GLOBAL_PACKAGES
|
||||
USER = host.data.USER
|
||||
|
||||
apt.packages(
|
||||
name=f'Install nodejs tools',
|
||||
no_recommends=True,
|
||||
packages=[
|
||||
'build-essential',
|
||||
'libssl-dev',
|
||||
'libtool',
|
||||
'cmake',
|
||||
'libcairo2-dev',
|
||||
'libpango1.0-de',
|
||||
'libpng-dev',
|
||||
'libgif-dev',
|
||||
'libjpeg-dev',
|
||||
'libvips-dev',
|
||||
'librsvg2-dev',
|
||||
'libpixman-1-dev',
|
||||
],
|
||||
_sudo = True,
|
||||
)
|
||||
|
||||
server.shell(
|
||||
commands=[
|
||||
"curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash",
|
||||
"omf install nvm",
|
||||
f"nvm install {VERSION}",
|
||||
f"nvm alias {ALIAS} {VERSION}",
|
||||
"set -gx NVM_DIR $HOME/.nvm",
|
||||
],
|
||||
_sudo=True,
|
||||
_su_user=USER,
|
||||
_use_su_login=True,
|
||||
_shell_executable='/usr/bin/fish'
|
||||
)
|
||||
|
||||
|
||||
server.shell(
|
||||
commands=[
|
||||
"npm install -g pm2 yarn local-web-server node-gyp inquirer execa @dotenvx/dotenvx"
|
||||
],
|
||||
_sudo=True,
|
||||
_su_user=USER,
|
||||
_use_su_login=True,
|
||||
_shell_executable='/usr/bin/fish'
|
||||
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'runtime:nodevm',
|
||||
name: 'Install nvm and nodejs with global packages',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
VERSION: z
|
||||
.nullable(z.string())
|
||||
.describe('Node.js version to install. It is recommended to use semver notation')
|
||||
.default('v22.20.0'),
|
||||
USER: z.string().describe('Username for which to install nodejs').default('vagrant'),
|
||||
ALIAS: z.string().describe('The alias for this node version').default('nodelts'),
|
||||
GLOBAL_PACKAGES: z
|
||||
.string()
|
||||
.describe('Space-separated list of global npm packages to install')
|
||||
.default('npm-check-updates'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
# TypeStack Install Cube
|
||||
|
||||
Deploys a Node.js/TypeScript application from a Git repository as a systemd service with Docker Compose and PM2 support.
|
||||
|
||||
## Features
|
||||
|
||||
- Clones Git repository
|
||||
- Installs dependencies with Yarn
|
||||
- Builds the application
|
||||
- Starts Docker Compose services
|
||||
- Creates a systemd service for automatic startup
|
||||
- Configures PM2 for process management
|
||||
- Automatic restart on failure
|
||||
|
||||
## Requirements
|
||||
|
||||
- Git (for cloning repository)
|
||||
- Yarn (for dependency management)
|
||||
- Docker and Docker Compose
|
||||
- PM2 (for process management)
|
||||
- Node.js/NVM installed
|
||||
- SSH key access to the repository (if using private repos)
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
### Required
|
||||
|
||||
- **USER**: System user to run the application (default: `teclabmin`)
|
||||
- **REPO**: Git repository URL (default: `git@github.com:bennidi/teclab-flintstone.git`)
|
||||
- **APP**: Application name/directory name (default: `flintstone`)
|
||||
|
||||
### Optional
|
||||
|
||||
- **ENV**: Application environment (default: `production`)
|
||||
- **AUTOSTART**: Enable and start service immediately (default: `True`)
|
||||
- **NODE_PATH**: Path to Node.js binaries (default: `/home/teclabmin/.nvm/versions/node/v21.7.3/bin`)
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"USER": "myuser",
|
||||
"REPO": "git@github.com:myorg/myapp.git",
|
||||
"APP": "myapp"
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"USER": "appuser",
|
||||
"REPO": "git@github.com:myorg/myapp.git",
|
||||
"APP": "myapp",
|
||||
"ENV": "staging",
|
||||
"AUTOSTART": false,
|
||||
"NODE_PATH": "/home/appuser/.nvm/versions/node/v20.0.0/bin"
|
||||
}
|
||||
```
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Clone Repository**: Clones the specified Git repository to `/home/<USER>/<APP>`
|
||||
2. **Install Dependencies**: Runs `yarn install` to install all dependencies
|
||||
3. **Build Application**: Runs `yarn build` to compile the application
|
||||
4. **Start Docker Services**: Runs `docker compose up -d` to start containerized services
|
||||
5. **Create Startup Script**: Creates `/home/<USER>/<APP>.service.sh` that:
|
||||
- Starts Docker Compose services
|
||||
- Starts PM2 with ecosystem.config.js
|
||||
6. **Create Systemd Service**: Creates `/etc/systemd/system/<APP>.service` that:
|
||||
- Runs after Docker service
|
||||
- Uses the specified user
|
||||
- Configures proper environment (HOME, PATH)
|
||||
- Auto-restarts on failure
|
||||
7. **Enable & Start Service**: Enables and starts the service (if AUTOSTART=True)
|
||||
|
||||
## Service Management
|
||||
|
||||
### Check service status
|
||||
|
||||
```bash
|
||||
sudo systemctl status <APP>
|
||||
```
|
||||
|
||||
### Start the service
|
||||
|
||||
```bash
|
||||
sudo systemctl start <APP>
|
||||
```
|
||||
|
||||
### Stop the service
|
||||
|
||||
```bash
|
||||
sudo systemctl stop <APP>
|
||||
```
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
sudo systemctl restart <APP>
|
||||
```
|
||||
|
||||
### View service logs
|
||||
|
||||
```bash
|
||||
sudo journalctl -u <APP> -f
|
||||
```
|
||||
|
||||
### Disable autostart
|
||||
|
||||
```bash
|
||||
sudo systemctl disable <APP>
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
After deployment:
|
||||
|
||||
```
|
||||
/home/<USER>/
|
||||
├── <APP>/ # Application directory
|
||||
│ ├── ecosystem.config.js # PM2 configuration
|
||||
│ ├── docker-compose.yml # Docker services
|
||||
│ └── ... # Application files
|
||||
├── <APP>.service.sh # Startup script
|
||||
/etc/systemd/system/
|
||||
└── <APP>.service # Systemd service file
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service fails to start
|
||||
|
||||
1. Check service logs:
|
||||
```bash
|
||||
sudo journalctl -u <APP> -n 50
|
||||
```
|
||||
|
||||
2. Verify Docker is running:
|
||||
```bash
|
||||
sudo systemctl status docker
|
||||
```
|
||||
|
||||
3. Check if Node.js path is correct:
|
||||
```bash
|
||||
which node
|
||||
which pm2
|
||||
```
|
||||
|
||||
### Repository clone fails
|
||||
|
||||
- Ensure SSH keys are properly configured for the user
|
||||
- Test SSH access: `ssh -T git@github.com`
|
||||
- Check repository URL is correct
|
||||
|
||||
### Docker Compose fails
|
||||
|
||||
- Verify Docker is installed and running
|
||||
- Check docker-compose.yml exists in the application directory
|
||||
- Ensure user has Docker permissions: `sudo usermod -aG docker <USER>`
|
||||
|
||||
### PM2 not starting
|
||||
|
||||
- Verify PM2 is installed: `pm2 --version`
|
||||
- Check ecosystem.config.js exists
|
||||
- Verify NODE_PATH includes PM2 binary location
|
||||
|
||||
## Notes
|
||||
|
||||
- The service type is set to `forking` to support PM2's daemon mode
|
||||
- Service will auto-restart on failure with a 5-second delay
|
||||
- Maximum 5 restart attempts in the burst period
|
||||
- The service waits for Docker to be ready before starting
|
||||
- Environment variables can be configured in the ecosystem.config.js file
|
||||
@@ -0,0 +1,27 @@
|
||||
from pyinfra.operations import systemd
|
||||
from pyinfra import host
|
||||
|
||||
|
||||
APP = host.data.APP
|
||||
|
||||
# Enable and start the service based on AUTOSTART flag
|
||||
if AUTOSTART:
|
||||
systemd.service(
|
||||
name=f'Enable {SERVICE_NAME} service',
|
||||
service=APP,
|
||||
enabled=True,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name=f'Start {SERVICE_NAME} service',
|
||||
service=APP,
|
||||
running=True,
|
||||
_sudo=True
|
||||
)
|
||||
else:
|
||||
server.shell(
|
||||
name=f'Service {SERVICE_NAME} created but not enabled (AUTOSTART=False)',
|
||||
commands=[f'echo "Service {SERVICE_NAME} is ready but not started. Enable with: sudo systemctl enable {APP} && sudo systemctl start {APP}"'],
|
||||
_sudo=False
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'service:autostart',
|
||||
name: 'Manage systemd service autostart',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
APP: z.string().describe('The name of the systemd service (e.g., flintstone)'),
|
||||
SERVICE_NAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Display name for the service')
|
||||
.default('Application'),
|
||||
AUTOSTART: z.boolean().describe('Should the service be enabled and started?').default(true),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
# ssh-authorize
|
||||
|
||||
**Authorize SSH public key for a user**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube adds a specific SSH public key to a user's `authorized_keys` file on the remote server, allowing them to log in via SSH using the corresponding private key.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **USER** (string, default: `'vagrant'`)
|
||||
- The username on the remote server to authorize.
|
||||
- If the user does not exist, `pyinfra` will attempt to create it (though a full user creation with shell/groups is better handled by `user-add`).
|
||||
|
||||
- **PUBKEY** (string, required)
|
||||
- The actual content of the public key (e.g., `ssh-ed25519 AAAA...`).
|
||||
|
||||
## Use Cases
|
||||
|
||||
Grant access to a developer:
|
||||
```javascript
|
||||
exec('ssh-authorize', {
|
||||
USER: 'developer',
|
||||
PUBKEY: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...'
|
||||
})
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,13 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server
|
||||
|
||||
USER = host.data.USER
|
||||
PUBKEY = host.data.PUBKEY
|
||||
|
||||
server.user(
|
||||
name=f"Authorize public key for {USER}",
|
||||
user=USER,
|
||||
public_keys=[PUBKEY],
|
||||
present=True,
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'ssh:authorize',
|
||||
name: 'Authorize SSH public key for a user',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
USER: z.string().describe('Username to authorize').default('vagrant'),
|
||||
PUBKEY: z.string().describe('SSH public key string').default(''),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
# ssh-keygen
|
||||
|
||||
**Generate SSH key for a given user**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube generates a new SSH key pair for a specified user, which can be used for secure, passwordless authentication to remote servers and services like GitHub, GitLab, or other SSH-accessible systems.
|
||||
|
||||
## What are SSH Keys?
|
||||
|
||||
SSH keys provide a more secure and convenient way to authenticate compared to passwords:
|
||||
|
||||
- **Public key**: Shared with servers/services you want to access (like GitHub)
|
||||
- **Private key**: Kept secret on your local machine, never shared
|
||||
- **Passphrase-free**: This cube generates keys without a passphrase for automation
|
||||
- **Algorithm support**: RSA (traditional) or Ed25519 (modern, recommended)
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Creates the `.ssh` directory with proper permissions (700)
|
||||
2. Generates an SSH key pair using the specified algorithm
|
||||
3. Saves the keys as `id_{SUFFIX}` and `id_{SUFFIX}.pub`
|
||||
4. Logs the public key to the console for easy copying
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **SUFFIX** (string, default: `'ed25519'`)
|
||||
- Suffix for keyname (e.g., `github` → `id_github.pub`)
|
||||
- Helps identify the purpose of the key
|
||||
|
||||
- **EMAIL** (string, default: `'undefined@bitsquare.dev'`)
|
||||
- Email address to associate with the SSH key
|
||||
- Used as a comment in the public key
|
||||
|
||||
- **ALGORITHM** (enum: `'rsa'` | `'ed25519'`, default: `'ed25519'`)
|
||||
- SSH key algorithm type
|
||||
- **Ed25519**: Modern, faster, more secure (recommended)
|
||||
- **RSA**: Traditional, widely supported
|
||||
|
||||
- **USER** (string, default: `'vagrant'`)
|
||||
- Username for which to generate the SSH key
|
||||
- Inherited from `user-add` dependency
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **user-add** - Creates the user account first
|
||||
|
||||
## Post-Installation
|
||||
|
||||
After the key is generated:
|
||||
|
||||
1. The public key will be logged to the console
|
||||
2. Copy the public key and add it to the target service:
|
||||
- **GitHub**: Settings → SSH and GPG keys → New SSH key
|
||||
- **GitLab**: Preferences → SSH Keys
|
||||
- **Remote server**: Add to `~/.ssh/authorized_keys`
|
||||
|
||||
3. SSH config is automatically set up for the key
|
||||
|
||||
## Key Locations
|
||||
|
||||
- Private key: `/home/{USER}/.ssh/id_{SUFFIX}`
|
||||
- Public key: `/home/{USER}/.ssh/id_{SUFFIX}.pub`
|
||||
|
||||
## Algorithm Comparison
|
||||
|
||||
**Ed25519** (Recommended):
|
||||
- Smaller keys (256-bit)
|
||||
- Faster generation and verification
|
||||
- More secure against certain attacks
|
||||
- Not supported on very old systems
|
||||
|
||||
**RSA**:
|
||||
- Larger keys (2048-4096 bit)
|
||||
- Universally supported
|
||||
- Slower than Ed25519
|
||||
- Well-tested and trusted
|
||||
|
||||
## Example Usage
|
||||
|
||||
Generate a key for GitHub access:
|
||||
```javascript
|
||||
exec('ssh-keygen', {
|
||||
SUFFIX: 'github',
|
||||
EMAIL: 'myemail@example.com',
|
||||
ALGORITHM: 'ed25519',
|
||||
USER: 'myuser'
|
||||
})
|
||||
```
|
||||
|
||||
This creates `id_github` and `id_github.pub` in `/home/myuser/.ssh/`.
|
||||
@@ -0,0 +1,46 @@
|
||||
from pyinfra.operations import files, server, python
|
||||
from pyinfra import host, logger
|
||||
import logging
|
||||
|
||||
|
||||
NAME=host.data.SUFFIX
|
||||
EMAIL=host.data.EMAIL
|
||||
ALGORITHM=host.data.ALGORITHM
|
||||
USER=host.data.USER
|
||||
|
||||
# Ensure the .ssh directory exists
|
||||
files.directory(
|
||||
name="Ensure .ssh directory exists",
|
||||
path=f"/home/{USER}/.ssh",
|
||||
present=True,
|
||||
mode=700,
|
||||
user=USER,
|
||||
group=USER,
|
||||
)
|
||||
|
||||
# Generate the SSH keypair
|
||||
server.shell(
|
||||
name=f"Generate SSH key id_{NAME}",
|
||||
commands=[
|
||||
f"ssh-keygen -t {ALGORITHM} -f /home/{USER}/.ssh/id_{NAME} -C '{EMAIL}' -N ''"
|
||||
]
|
||||
)
|
||||
|
||||
# Print the public key
|
||||
result = server.shell(
|
||||
name="Print public key",
|
||||
commands=[f"cat /home/{USER}/.ssh/id_{NAME}.pub"],
|
||||
)
|
||||
|
||||
def callback():
|
||||
# 🔹 Extract and log the key output
|
||||
if result.stdout:
|
||||
logger.info(f"Public Key for {USER}: {result.stdout.strip()}")
|
||||
else:
|
||||
logger.warning(f"No public key found for {USER} at /home/{USER}/.ssh/id_{NAME}.pub")
|
||||
|
||||
python.call(
|
||||
name="Log public key",
|
||||
function=callback,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'ssh:keygen',
|
||||
name: 'Generate SSH key for a given $USER',
|
||||
dependencies: () => ['user:add'],
|
||||
schema: z.object({
|
||||
SUFFIX: z
|
||||
.string()
|
||||
.describe('Suffix for keyname, e.g. github => id_github.pub')
|
||||
.default('ed25519'),
|
||||
EMAIL: z
|
||||
.string()
|
||||
.describe('Email address to associate with the SSH key')
|
||||
.default('undefined@bitsquare.dev'),
|
||||
ALGORITHM: z.enum(['rsa', 'ed25519']).describe('SSH key algorithm type').default('ed25519'),
|
||||
USER: z.string().describe('Username for which to generate the SSH key').default('vagrant'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
# ssh-keyman
|
||||
|
||||
**Deploy existing SSH keys to host**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube deploys pre-existing SSH key pairs from your local machine to a remote server, configuring them for automatic use with specified hosts (like GitHub, GitLab, etc.).
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Copies SSH keys to the server**
|
||||
- Transfers both private and public keys from local directory to remote `.ssh` folder
|
||||
- Sets correct file permissions (600 for private, 644 for public)
|
||||
|
||||
2. **Configures SSH client**
|
||||
- Creates/updates `.ssh/config` to use the deployed key for specified hosts
|
||||
- Disables strict host key checking for easier automation
|
||||
- Maps each host to use the correct identity file
|
||||
|
||||
3. **Ensures security**
|
||||
- Sets proper directory permissions (700 for `.ssh`)
|
||||
- Ensures keys are owned by the specified user
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **KEY_NAME** (string, default: `'id_ed25519'`)
|
||||
- Name of the SSH key file (without extension)
|
||||
- Must exist in the KEY_DIR directory locally
|
||||
|
||||
- **USER** (string, default: `'vagrant'`)
|
||||
- Username for which to deploy the SSH key
|
||||
- Inherited from `user-add` dependency
|
||||
|
||||
- **HOSTS** (string, default: `'github.com'`)
|
||||
- Space-separated list of hosts to add to known_hosts
|
||||
- Example: `'github.com gitlab.com bitbucket.org'`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **user-add** - Creates the user account first
|
||||
|
||||
## Use Cases
|
||||
|
||||
Deploy GitHub SSH key:
|
||||
|
||||
```javascript
|
||||
exec('ssh-keyman', {
|
||||
KEY_NAME: 'id_github',
|
||||
HOSTS: 'github.com'
|
||||
})
|
||||
```
|
||||
|
||||
Deploy key for multiple Git services:
|
||||
|
||||
```javascript
|
||||
exec('ssh-keyman', {
|
||||
KEY_NAME: 'id_git',
|
||||
HOSTS: 'github.com gitlab.com bitbucket.org'
|
||||
})
|
||||
```
|
||||
|
||||
## What Gets Configured
|
||||
|
||||
After deployment, the `.ssh/config` file will contain entries like:
|
||||
|
||||
```
|
||||
Host github.com
|
||||
IdentityFile /home/{USER}/.ssh/id_github
|
||||
StrictHostKeyChecking no
|
||||
```
|
||||
|
||||
This means when you run `git clone git@github.com:user/repo.git`, it will automatically use the deployed key.
|
||||
|
||||
## Key File Requirements
|
||||
|
||||
The local KEY_DIR must contain:
|
||||
|
||||
- `{KEY_NAME}` - Private key file
|
||||
- `{KEY_NAME}.pub` - Public key file
|
||||
|
||||
For example, if `KEY_NAME=id_github`, you need:
|
||||
|
||||
- `./vault/tmp/id_github`
|
||||
- `./vault/tmp/id_github.pub`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Private keys are sensitive**: Ensure your local KEY_DIR is secure
|
||||
- **StrictHostKeyChecking disabled**: Convenient but less secure
|
||||
- Consider enabling it for production: Edit `/home/{USER}/.ssh/config`
|
||||
- **Backup your keys**: Keep secure copies of private keys
|
||||
- **Use different keys**: Consider separate keys for different services
|
||||
|
||||
## Post-Installation
|
||||
|
||||
Test SSH connection:
|
||||
|
||||
```bash
|
||||
ssh -T git@github.com
|
||||
# Should show: "Hi username! You've successfully authenticated..."
|
||||
```
|
||||
|
||||
Clone a repository:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:user/repo.git
|
||||
# Should work without prompting for credentials
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
from pyinfra.operations import server, files, apt, systemd
|
||||
from pyinfra import host
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
|
||||
def get_keyman_config():
|
||||
"""Get keyman configuration by calling keyman --print-config"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['keyman', '--print-config'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
# Load keyman config for defaults
|
||||
keyman_config = get_keyman_config()
|
||||
|
||||
USER = host.data.USER
|
||||
KEY = host.data.KEY_NAME
|
||||
|
||||
# Use KEY_DIR from host data, or fall back to keyman config tmpDir
|
||||
DIR = host.data.get('KEY_DIR')
|
||||
if not DIR and keyman_config:
|
||||
DIR = keyman_config.get('tmpDir')
|
||||
if not DIR:
|
||||
DIR = '../../vault/tmp' # Final fallback
|
||||
|
||||
# Support multiple hosts separated by space
|
||||
HOSTS = map(str.lstrip, str(host.data.HOSTS).split(' '))
|
||||
|
||||
# 🔹 Define remote paths
|
||||
SSH_DIR = f"/home/{USER}/.ssh" if USER != "root" else "/root/.ssh"
|
||||
PRIVATE_KEY_PATH = f"{SSH_DIR}/{KEY}"
|
||||
PUBLIC_KEY_PATH = f"{SSH_DIR}/{KEY}.pub"
|
||||
|
||||
# Ensure the .ssh directory exists
|
||||
files.directory(
|
||||
name="Ensure .ssh directory exists",
|
||||
path=SSH_DIR,
|
||||
present=True,
|
||||
mode=700,
|
||||
user=USER,
|
||||
group=USER,
|
||||
_sudo=True
|
||||
)
|
||||
# Copy private key to the remote server
|
||||
files.put(
|
||||
name="Copy private key",
|
||||
src=f"{DIR}/{KEY}",
|
||||
dest=PRIVATE_KEY_PATH,
|
||||
mode="600",
|
||||
user=USER,
|
||||
group=USER,
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
# Copy public key to the remote server
|
||||
files.put(
|
||||
name="Copy public key",
|
||||
src=f"{DIR}/{KEY}.pub",
|
||||
dest=PUBLIC_KEY_PATH,
|
||||
mode="644",
|
||||
user=USER,
|
||||
group=USER,
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
# Ensure correct permissions for the private key
|
||||
server.shell(
|
||||
name="Set correct permissions for private key",
|
||||
commands=[f"chmod 600 {PRIVATE_KEY_PATH}"],
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
files.file(
|
||||
name="Ensure .ssh/config directory exists",
|
||||
path=f"/home/{USER}/.ssh/config",
|
||||
present=True,
|
||||
user=USER,
|
||||
group=USER,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
for host in HOSTS:
|
||||
files.line(
|
||||
name=f"Configure SSH key for {host}",
|
||||
path=f"{SSH_DIR}/config",
|
||||
line=f"Host {host}\n IdentityFile {SSH_DIR}/{KEY}\n StrictHostKeyChecking no",
|
||||
_sudo=True
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'ssh:keyman',
|
||||
name: 'Deploy an ssh key managed by keyman',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
KEY_NAME: z
|
||||
.string()
|
||||
.describe('Name of the SSH key file (without extension)')
|
||||
.default('id_ed25519'),
|
||||
USER: z.string().describe('Username for which to deploy the SSH key').default('vagrant'),
|
||||
HOSTS: z
|
||||
.string()
|
||||
.describe('Space-separated list of hosts to add to known_hosts')
|
||||
.default('github.com'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
# user-add
|
||||
|
||||
**Add a user with Fish shell and tools**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube creates a new user account with a modern shell environment (Fish), SSH key authentication, and enhanced productivity tools pre-configured.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. **Creates a new user account**
|
||||
- Sets up home directory with proper permissions
|
||||
- Configures password authentication
|
||||
- Adds user to specified groups (e.g., `docker`, `sudo`)
|
||||
- Sets Fish as the default login shell
|
||||
|
||||
2. **Configures SSH access**
|
||||
- Deploys the specified SSH public key for passwordless authentication
|
||||
- Creates `.ssh` directory with proper permissions
|
||||
- Sets up SSH config file
|
||||
- Configures SSH agent auto-loading for Fish shell
|
||||
|
||||
3. **Installs Fish shell enhancements**
|
||||
- Installs **Oh My Fish** (OMF) - Fish shell framework with themes and plugins
|
||||
- Deploys custom Fish configuration (`config.fish`)
|
||||
- Sets up Fish rc directory for modular configurations
|
||||
|
||||
4. **Creates workspace directories**
|
||||
- Creates `/home/{USER}/tmp` directory for temporary files
|
||||
|
||||
## Configuration
|
||||
|
||||
### Parameters
|
||||
|
||||
- **USER** (string, auto-generated)
|
||||
- Username for the new user account
|
||||
- Default: `userXXXXX` (randomly generated 5-character suffix)
|
||||
|
||||
- **PASSWORD** (string, **secret**)
|
||||
- Password for the new user account
|
||||
- Default: the literal `changeme` — a placeholder, not a credential. Change it
|
||||
on first login, or pass a real one.
|
||||
- Declared in the manifest's `secrets`, so it is never written to a session or
|
||||
history file and is masked in printed commands. A replay asks for it again.
|
||||
- It used to default to a randomly generated password. That was removed: since
|
||||
the value is not recorded, an unattended run created an account with a
|
||||
credential nobody had seen, and replaying that run produced a different one.
|
||||
|
||||
- **GROUPS** (string, default: `''`)
|
||||
- Comma-separated list of additional groups (e.g., `"docker,sudo"`)
|
||||
- Common groups:
|
||||
- `docker` - Run Docker without sudo
|
||||
- `sudo` - Administrative privileges
|
||||
- `www-data` - Web server file access
|
||||
|
||||
- **PUBKEY** (string, **required** — no default)
|
||||
- SSH public key to authorize for the user
|
||||
- Should be your public key for passwordless SSH access
|
||||
- There is deliberately no default. It used to be a specific personal key, so
|
||||
accepting the default authorized *someone else's* key on the new account.
|
||||
No key would be a sensible guess, so the cube asks instead.
|
||||
- Because it is required, `--use-defaults` refuses to run this cube unless
|
||||
`PUBKEY` comes from `env` in `.nopyrc.json`, a dependency, or a hook.
|
||||
- Submitting an empty value at the prompt authorizes no key at all (the account
|
||||
is still created, with password login only).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **apt:essentials** - Provides Fish shell and basic tools
|
||||
|
||||
## What is Fish?
|
||||
|
||||
Fish (Friendly Interactive Shell) is a modern command-line shell that focuses on usability:
|
||||
|
||||
- **Smart autosuggestions**: Suggests commands as you type based on history
|
||||
- **Syntax highlighting**: Color-codes commands in real-time
|
||||
- **Tab completions**: Comprehensive, discoverable command completions
|
||||
- **No configuration needed**: Works great out of the box
|
||||
|
||||
## Post-Installation
|
||||
|
||||
After deployment:
|
||||
- SSH into the server as the new user: `ssh {USER}@server`
|
||||
- Your SSH key will be pre-authorized (no password needed if using key)
|
||||
- Fish shell will start automatically with OMF installed
|
||||
- SSH agent auto-loads to manage your SSH keys
|
||||
|
||||
## Notes
|
||||
|
||||
- The user's home directory is created at `/home/{USER}`
|
||||
- Fish configuration is stored in `/home/{USER}/.config/fish/`
|
||||
- Oh My Fish provides package management: `omf install <package>`
|
||||
- To switch shells: `chsh -s /bin/bash` (or back to fish: `chsh -s /usr/bin/fish`)
|
||||
|
||||
---
|
||||
|
||||
# 📌 Most Useful Fish Key Bindings (with Fisher Extensions)
|
||||
|
||||
## 🐟 Default Fish Key Bindings
|
||||
|
||||
- `Ctrl + C` → Cancel the current command
|
||||
- `Ctrl + D` → Exit the shell (or logout if in SSH)
|
||||
- `Ctrl + L` → Clear the terminal
|
||||
- `Ctrl + R` → Search command history (enhanced by `fzf.fish`)
|
||||
- `Ctrl + U` → Delete the entire command line
|
||||
- `Ctrl + W` → Delete the last word
|
||||
- `Alt + ← / →` → Move backward/forward by a word
|
||||
|
||||
## 🔍 Enhanced with `fzf.fish`
|
||||
|
||||
- `Ctrl + R` → **Fuzzy search command history**
|
||||
- `Ctrl + T` → **Fuzzy search and insert file path**
|
||||
- `Alt + C` → **Fuzzy search directories (`cd` with `z`)**
|
||||
|
||||
## 📂 Directory Navigation (with `z`)
|
||||
|
||||
- `z <dir>` → Jump to a frequently used directory
|
||||
- `z -l` → List most-used directories
|
||||
- `z -c` → Remove a directory from `z`'s database
|
||||
|
||||
## 🔄 Process & Job Management
|
||||
|
||||
- `Ctrl + Z` → Suspend the current process
|
||||
- `fg` → Bring a suspended process back to foreground
|
||||
- `jobs` → List background jobs
|
||||
|
||||
## 🎨 Other Handy Shortcuts
|
||||
|
||||
- `fish_vi_key_bindings` → Enable Vi mode (press `Esc` for normal mode)
|
||||
- `Ctrl + G` → Show Git status (if using `fzf.fish`)
|
||||
- `Ctrl + E` → Edit command line in `$EDITOR`
|
||||
|
||||
## ⚙️ Useful Commands for Key Binding
|
||||
|
||||
```fish
|
||||
# Set Fish default key bindings
|
||||
fish_default_key_bindings
|
||||
|
||||
# Enable Vi mode
|
||||
fish_vi_key_bindings
|
||||
|
||||
# Rebind a custom key (Example: Ctrl + G for git status)
|
||||
bind \cg 'git status'
|
||||
@@ -0,0 +1,10 @@
|
||||
if status is-interactive
|
||||
# Commands to run in interactive sessions can go here
|
||||
# Execute all scripts in ~/.config/fish/rc/ on shell startup
|
||||
for script in ~/.config/fish/rc/*.fish
|
||||
if test -f $script
|
||||
source $script
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server, files, apt
|
||||
from io import StringIO
|
||||
|
||||
# Define the username, password, and public key for the new admin user
|
||||
USER = host.data.USER
|
||||
HOME_DIR = f"/home/{USER}"
|
||||
TMP_DIR = f"{HOME_DIR}/tmp"
|
||||
PASSWORD = host.data.PASSWORD
|
||||
# An empty submission at the prompt must not become an empty authorized_keys
|
||||
# line, so an absent key means no key rather than a blank one.
|
||||
PUBKEY = host.data.PUBKEY
|
||||
PUBKEYS = [PUBKEY] if PUBKEY and str(PUBKEY).strip() else []
|
||||
GROUPS = list(filter(None, map(str.strip, str(host.data.GROUPS).split())))
|
||||
FISH_PATH = "/usr/bin/fish"
|
||||
FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish"
|
||||
FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish"
|
||||
FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc"
|
||||
SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish"
|
||||
|
||||
apt.packages(
|
||||
name='Ensure fish shell is installed',
|
||||
packages=[ 'fish'],
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
|
||||
# Ensure the user exists with a login shell
|
||||
server.user(
|
||||
name=f"Create user {USER} [{GROUPS}]",
|
||||
present=True,
|
||||
user=USER,
|
||||
password=PASSWORD,
|
||||
create_home=True,
|
||||
groups=GROUPS,
|
||||
shell=FISH_PATH,
|
||||
public_keys=PUBKEYS,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]:
|
||||
files.directory(
|
||||
name=f"Ensure {dir} directory exists",
|
||||
path=dir,
|
||||
present=True,
|
||||
mode=700,
|
||||
user=USER,
|
||||
group=USER,
|
||||
_sudo=True,
|
||||
_sudo_user=USER,
|
||||
_use_sudo_login=True
|
||||
)
|
||||
|
||||
files.file(
|
||||
name="Ensure .ssh/config exists",
|
||||
path=f"{HOME_DIR}/.ssh/config",
|
||||
present=True,
|
||||
user=USER,
|
||||
group=USER,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
server.shell(
|
||||
name=f"Install OMF(Oh My Fish) for {USER}",
|
||||
commands=[
|
||||
f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf",
|
||||
f"fish install-omf --yes --noninteractive",
|
||||
],
|
||||
_sudo=True,
|
||||
_sudo_user=USER,
|
||||
_use_sudo_login=True
|
||||
)
|
||||
|
||||
files.put(
|
||||
name="Add SSH agent auto-load script to Fish rc directory",
|
||||
src="ssh-agent.fish",
|
||||
dest=SSH_AGENT_SCRIPT,
|
||||
user=USER,
|
||||
group=USER,
|
||||
mode="755", # Make it executable
|
||||
_sudo=True,
|
||||
|
||||
)
|
||||
|
||||
files.put(
|
||||
name="Add custom config.fish",
|
||||
src="config.fish",
|
||||
dest=FISH_CONFIG_FILE,
|
||||
user=USER,
|
||||
group=USER,
|
||||
mode="755", # Make it executable
|
||||
_sudo=True,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Manifest, uniqid } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'user:add',
|
||||
name: 'Add a user with fish shell and tools',
|
||||
dependencies: () => ['apt:essentials'],
|
||||
secrets: ['PASSWORD'],
|
||||
schema: z.object({
|
||||
USER: z
|
||||
.string()
|
||||
.describe('Username for the new user account')
|
||||
.default(() => `user${uniqid(5)}`),
|
||||
// A fixed placeholder, not a generated one: the password is never recorded
|
||||
// in a session, so a generated default meant every run produced credentials
|
||||
// nobody had seen and a replay produced different ones again.
|
||||
PASSWORD: z.string().describe('Password for the new user account').default('changeme'),
|
||||
GROUPS: z
|
||||
.string()
|
||||
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")')
|
||||
.default(''),
|
||||
// No default on purpose. This used to carry a specific personal key, which
|
||||
// meant an unattended run authorised someone else's key on the new account.
|
||||
// Leaving it required makes `--use-defaults` refuse by name instead of
|
||||
// guessing, and there is no key that would be a sensible guess.
|
||||
PUBKEY: z.string().describe('SSH public key to authorize for the user'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
# Start SSH agent if not already running
|
||||
if not set -q SSH_AUTH_SOCK
|
||||
eval (ssh-agent -c)
|
||||
end
|
||||
|
||||
# Add all private SSH keys in ~/.ssh to the agent
|
||||
for key in ~/.ssh/id_*;
|
||||
if test -f $key; and not string match -q "*pub" $key
|
||||
ssh-add $key 2>/dev/null
|
||||
end
|
||||
end
|
||||
# Export user and group ID for Docker
|
||||
set -x UID (id -u)
|
||||
set -x GID (id -g)
|
||||
@@ -0,0 +1,52 @@
|
||||
# user:edit
|
||||
|
||||
**Modify an existing user's password or group membership**
|
||||
|
||||
## Purpose
|
||||
|
||||
This cube allows you to update existing user accounts on the target system. It can be used to change passwords, add users to new groups (like `docker` or `sudo`), or revoke group memberships.
|
||||
|
||||
## What This Cube Does
|
||||
|
||||
1. Identifies the existing user on the target system
|
||||
2. Updates the user's password if `PASSWORD` is provided
|
||||
3. Adds the user to the groups specified in `GROUPS`
|
||||
4. Removes the user from the groups specified in `GROUPS_ABSENT`
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Type | Description | Required |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `USER` | `string` | The username of the account to modify | Yes |
|
||||
| `PASSWORD` | `string` | New password for the user. **Secret**: never recorded in a session or history file, masked in printed commands, re-prompted on replay. | No |
|
||||
| `GROUPS` | `string` | Comma-separated list of groups to ADD (e.g., `docker,sudo`) | No |
|
||||
| `GROUPS_ABSENT` | `string` | Comma-separated list of groups to REMOVE | No |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `apt/essentials`: Standard system utilities.
|
||||
|
||||
## Usage
|
||||
|
||||
### Changing a Password
|
||||
|
||||
```bash
|
||||
nopy install user:edit --env USER=myuser --env PASSWORD=newsecurepassword
|
||||
```
|
||||
|
||||
### Adding a User to the Docker Group
|
||||
|
||||
```bash
|
||||
nopy install user:edit --env USER=myuser --env GROUPS=docker
|
||||
```
|
||||
|
||||
### Revoking Sudo Access
|
||||
|
||||
```bash
|
||||
nopy install user:edit --env USER=myuser --env GROUPS_ABSENT=sudo
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- When setting passwords via the CLI, they may be visible in your shell history. Consider using a session file or interactive prompts for sensitive values.
|
||||
- Changing your own user's groups or password may require a re-login to take full effect.
|
||||
@@ -0,0 +1,24 @@
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import server
|
||||
|
||||
# [agnt://cogen/cogen/user-edit-2]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||
|
||||
"""
|
||||
Deployment script for user:edit.
|
||||
Updates password and group membership for an existing user.
|
||||
"""
|
||||
|
||||
USER = host.data.USER
|
||||
PASSWORD = host.data.get('PASSWORD')
|
||||
GROUPS = [g.strip() for g in str(host.data.get('GROUPS', '')).split(',') if g.strip()]
|
||||
GROUPS_ABSENT = [g.strip() for g in str(host.data.get('GROUPS_ABSENT', '')).split(',') if g.strip()]
|
||||
|
||||
# Update user details
|
||||
server.user(
|
||||
name=f"Update user {USER}",
|
||||
user=USER,
|
||||
password=PASSWORD,
|
||||
groups=GROUPS,
|
||||
groups_absent=GROUPS_ABSENT,
|
||||
_sudo=True,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
// [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||
|
||||
/**
|
||||
* Manifest for the user:edit cube.
|
||||
* Allows modifying existing user accounts (password, groups).
|
||||
*/
|
||||
export default Manifest({
|
||||
id: 'user:edit',
|
||||
name: 'user:edit - Modify an existing user account',
|
||||
dependencies: () => [],
|
||||
secrets: ['PASSWORD'],
|
||||
schema: z.object({
|
||||
USER: z.string().describe('The username of the account to modify'),
|
||||
PASSWORD: z.string().optional().describe('New password for the user (optional)'),
|
||||
GROUPS: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Comma-separated list of groups the user SHOULD be in (optional)'),
|
||||
GROUPS_ABSENT: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Comma-separated list of groups to REMOVE from the user (optional)'),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@bitsquare/cubes-core",
|
||||
"version": "1.0.0-alpha0",
|
||||
"description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.",
|
||||
"keywords": [
|
||||
"nopy",
|
||||
"nopy-cubes",
|
||||
"pyinfra",
|
||||
"deployment",
|
||||
"infrastructure"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "bitsquare",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
|
||||
"directory": "packages/cubes-core"
|
||||
},
|
||||
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/cubes-core",
|
||||
"bugs": {
|
||||
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"nopy": {
|
||||
"cubes": [
|
||||
"./cubes"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"cubes",
|
||||
"!cubes/**/*.log",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitsquare/nopy-cube": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bitsquare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,69 @@
|
||||
# @bitsquare/nopy-cube
|
||||
|
||||
The authoring surface for [nopy](https://www.npmjs.com/package/@bitsquare/nopy)
|
||||
cubes — the `Manifest` factory, the `Cube` class, and the types around them.
|
||||
|
||||
A cube manifest ships nothing but data, so it should not have to depend on a CLI
|
||||
to describe itself. This package is what a **cube bundle** depends on: no
|
||||
`commander`, no `inquirer`, no `execa`, no process spawning. `@bitsquare/nopy`
|
||||
re-exports everything here, so a manifest that already imports from
|
||||
`@bitsquare/nopy` keeps working unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm add @bitsquare/nopy-cube zod
|
||||
```
|
||||
|
||||
`zod` is a **peer dependency** on purpose: the manifest, the schema it builds and
|
||||
the `Manifest` factory should all see the same copy.
|
||||
|
||||
## Writing a manifest
|
||||
|
||||
```js
|
||||
// cubes/net/tailscale/manifest.mjs
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'net:tailscale',
|
||||
name: 'Tailscale',
|
||||
schema: z.object({
|
||||
AUTH_KEY: z.string().describe('Tailscale auth key'),
|
||||
ACCEPT_ROUTES: z.boolean().describe('Accept advertised routes').default(true),
|
||||
}),
|
||||
secrets: ['AUTH_KEY'],
|
||||
dependencies: (vars) => (vars.ACCEPT_ROUTES ? ['net:ip-forwarding'] : []),
|
||||
before: [async (ctx, vars) => ctx.exec('apt:essentials', {})],
|
||||
});
|
||||
```
|
||||
|
||||
Every schema field should carry a `.describe()` — nopy uses it as the prompt
|
||||
label — and a `.default()` wherever a sensible one exists, so `--use-defaults`
|
||||
can run the cube without prompting.
|
||||
|
||||
`secrets` names the schema keys that hold sensitive values. Nopy keeps those out
|
||||
of session and history files and masks them in every command it prints; it does
|
||||
not infer them, so a key nothing declares is recorded and printed in the clear.
|
||||
Each entry must be a key of `schema` — naming anything else is a manifest error.
|
||||
Give a secret a placeholder `.default()` rather than a real credential: a default
|
||||
lives in the manifest, where none of that protection reaches it.
|
||||
|
||||
The manifest lives next to a `deploy.py` in the same directory; together they
|
||||
make a cube. See the
|
||||
[nopy README](https://www.npmjs.com/package/@bitsquare/nopy) for the full cube
|
||||
contract and for how to publish a directory of cubes as a bundle.
|
||||
|
||||
## Exports
|
||||
|
||||
| Export | What it is |
|
||||
| ----------------------------------- | -------------------------------------------------------------- |
|
||||
| `Manifest(opts)` | Builds a manifest, filling in `id`, `schema`, `secrets`, `before`, `after` |
|
||||
| `createManifest` / `manifest` | Aliases of `Manifest` |
|
||||
| `Cube` | A loaded manifest plus its directory; `getDefaults()`, `requiredKeys()`, `secrets`, `isSecret()` |
|
||||
| `zodKind` / `zodInner` | Instance-agnostic zod introspection, safe across zod copies |
|
||||
| `AnyObjectSchema`, `CubeVariables`, `DependencySpec`, `Hook`, `HookContext`, `CubeSource`, `LoadResult` | types |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@bitsquare/nopy-cube",
|
||||
"version": "1.0.0-alpha0",
|
||||
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
|
||||
"keywords": [
|
||||
"nopy",
|
||||
"pyinfra",
|
||||
"deployment",
|
||||
"infrastructure"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "bitsquare",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
|
||||
"directory": "packages/nopy-cube"
|
||||
},
|
||||
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/nopy-cube",
|
||||
"bugs": {
|
||||
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module cubes/factories
|
||||
* @module factories
|
||||
*/
|
||||
|
||||
import { type AnyObjectSchema, Manifest } from './types.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @bitsquare/nopy-cube — the authoring surface for nopy cubes.
|
||||
*
|
||||
* Everything a `manifest.mjs` needs and nothing else: no CLI, no prompts, no
|
||||
* process spawning. `@bitsquare/nopy` re-exports all of it, so a manifest can
|
||||
* import from either package.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export {
|
||||
createManifest,
|
||||
ManifestFactory,
|
||||
manifest,
|
||||
} from './factories.js';
|
||||
export type {
|
||||
AnyObjectSchema,
|
||||
CubeSource,
|
||||
CubeVariables,
|
||||
DependencySpec,
|
||||
Hook,
|
||||
HookContext,
|
||||
LoadResult,
|
||||
} from './types.js';
|
||||
export {
|
||||
Cube,
|
||||
Manifest,
|
||||
zodInner,
|
||||
zodKind,
|
||||
} from './types.js';
|
||||
export { uniqid } from './utils.js';
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module cubes/types
|
||||
* @module types
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -47,6 +47,18 @@ export interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
|
||||
name: string;
|
||||
/** Zod schema for validating cube variables */
|
||||
schema: Schema;
|
||||
/**
|
||||
* Schema keys holding secrets. Their values are never written to a session
|
||||
* file, and are masked wherever a command or a variable would be printed.
|
||||
*
|
||||
* A plain array rather than schema-level metadata on purpose: `.meta()` and
|
||||
* `.describe()` both store into zod's global registry, which is per-copy — a
|
||||
* manifest that builds its schema with its own zod writes the marker into a
|
||||
* registry this process cannot read. A missed `.describe()` costs an ugly
|
||||
* prompt label; a missed secret marker writes a password to disk, so this one
|
||||
* cannot be allowed to fail open. See {@link zodKind} for the same hazard.
|
||||
*/
|
||||
secrets?: string[];
|
||||
/** Dynamic dependency resolver based on collected variables */
|
||||
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
|
||||
/** Hooks to run before cube execution */
|
||||
@@ -65,6 +77,7 @@ export function Manifest<Schema extends AnyObjectSchema>(
|
||||
id: opts.id ?? '',
|
||||
name: opts.name,
|
||||
schema: opts.schema ?? (z.object({}) as unknown as Schema),
|
||||
secrets: opts.secrets ?? [],
|
||||
dependencies: opts.dependencies,
|
||||
before: opts.before ?? [],
|
||||
after: opts.after ?? [],
|
||||
@@ -189,6 +202,15 @@ export class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
|
||||
.filter(([, zodType]) => !zodType.safeParse(undefined).success)
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
/** Schema keys the manifest declared as secrets. */
|
||||
get secrets(): string[] {
|
||||
return this.manifest.secrets ?? [];
|
||||
}
|
||||
|
||||
isSecret(key: string): boolean {
|
||||
return this.secrets.includes(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module cubes/utils
|
||||
* @module utils
|
||||
*/
|
||||
|
||||
/**
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Tests for cubes/factories module
|
||||
* Tests for the manifest factories
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { createManifest, manifest } from '../src/cubes/factories.js';
|
||||
import { createManifest, manifest } from '../src/factories.js';
|
||||
|
||||
describe('createManifest', () => {
|
||||
it('creates manifest with basic properties', () => {
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* A schema that behaves like zod's but does not share zod's prototypes.
|
||||
*
|
||||
* Once cubes arrive from `node_modules`, the schema a manifest builds may come
|
||||
* from a *second* copy of zod — its own dependency, or one shipped inside a
|
||||
* bundle. Such a schema is structurally identical and `instanceof` blind to it.
|
||||
* Rebuilding the nodes as plain objects reproduces that from inside a single
|
||||
* process, so anything that reads zod's internals stays pinned to `def.type`.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
/** Strips the prototype off a schema node and everything it wraps. */
|
||||
function strip(node: unknown): unknown {
|
||||
const def = { ...(node as { def: Record<string, unknown> }).def };
|
||||
if (def.innerType) def.innerType = strip(def.innerType);
|
||||
return { def };
|
||||
}
|
||||
|
||||
export function foreignZodSchema<S extends z.ZodObject<any>>(schema: S): S {
|
||||
return {
|
||||
// Parsing is not what is under test — delegate it and keep the real
|
||||
// behaviour, so only the introspection path sees the foreign nodes.
|
||||
safeParse: (value: unknown) => schema.safeParse(value),
|
||||
shape: Object.fromEntries(
|
||||
Object.entries(schema.shape).map(([key, node]) => [key, strip(node)])
|
||||
),
|
||||
} as unknown as S;
|
||||
}
|
||||
@@ -5,12 +5,39 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { Cube, Manifest } from '../src/cubes/types.js';
|
||||
import { Cube, Manifest } from '../src/types.js';
|
||||
import { foreignZodSchema } from './helpers/foreign-zod.js';
|
||||
|
||||
const cube = (schema: z.ZodObject<any>) =>
|
||||
new Cube(Manifest.create({ id: 'c', name: 'C', schema }), '/cubes/c', 'deploy.py');
|
||||
|
||||
describe('Cube', () => {
|
||||
it('reads id and name off the manifest', () => {
|
||||
const c = cube(z.object({}));
|
||||
|
||||
expect(c.id).toBe('c');
|
||||
expect(c.name).toBe('C');
|
||||
});
|
||||
|
||||
it('defaults its source to its own directory', () => {
|
||||
// What the loader overrides when a cube arrives from a package; a cube
|
||||
// built by hand still has to answer the question.
|
||||
expect(cube(z.object({})).source).toEqual({ type: 'dir', dir: '/cubes/c' });
|
||||
});
|
||||
|
||||
it('keeps the source it was constructed with', () => {
|
||||
const source = { type: 'package' as const, packageName: '@acme/cubes-net', dir: '/pkg/cubes' };
|
||||
const c = new Cube(
|
||||
Manifest.create({ id: 'c', name: 'C' }),
|
||||
'/pkg/cubes/c',
|
||||
'deploy.py',
|
||||
source
|
||||
);
|
||||
|
||||
expect(c.source).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cube.getDefaults', () => {
|
||||
it('resolves every default when the whole schema parses', () => {
|
||||
const c = cube(
|
||||
@@ -109,3 +136,36 @@ describe('Cube.requiredKeys', () => {
|
||||
expect(c.requiredKeys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cube.secrets', () => {
|
||||
it('is empty when the manifest declares none', () => {
|
||||
const c = cube(z.object({ PASSWORD: z.string().default('x') }));
|
||||
|
||||
expect(c.secrets).toEqual([]);
|
||||
// No name-based guessing: only what the manifest says.
|
||||
expect(c.isSecret('PASSWORD')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports what the manifest declared', () => {
|
||||
const c = new Cube(
|
||||
Manifest.create({
|
||||
id: 'c',
|
||||
name: 'C',
|
||||
schema: z.object({ USER: z.string(), PASSWORD: z.string() }),
|
||||
secrets: ['PASSWORD'],
|
||||
}),
|
||||
'/cubes/c',
|
||||
'deploy.py'
|
||||
);
|
||||
|
||||
expect(c.secrets).toEqual(['PASSWORD']);
|
||||
expect(c.isSecret('PASSWORD')).toBe(true);
|
||||
expect(c.isSecret('USER')).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to an empty list on a manifest built by hand', () => {
|
||||
const c = new Cube({ id: 'c', name: 'C', schema: z.object({}) }, '/cubes/c', 'deploy.py');
|
||||
|
||||
expect(c.secrets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Tests for cubes/utils module
|
||||
* Tests for the uniqid helper
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { uniqid } from '../src/cubes/utils.js';
|
||||
import { uniqid } from '../src/utils.js';
|
||||
|
||||
describe('uniqid', () => {
|
||||
it('generates string of default length (5)', () => {
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2020"],
|
||||
"composite": true,
|
||||
"module": "NodeNext",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["coverage", "node_modules", "dist"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json-summary', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
// Pure re-export barrel: no logic to cover.
|
||||
'src/index.ts',
|
||||
],
|
||||
thresholds: {
|
||||
branches: 85,
|
||||
functions: 85,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+112
-17
@@ -33,7 +33,7 @@ Nopy wraps pyinfra with structure, validation, and an interactive experience for
|
||||
|
||||
A cube is a **directory** containing two files:
|
||||
|
||||
- **JavaScript manifest**: `manifest.mjs` defining schema, dependencies, defaults, and hooks
|
||||
- **JavaScript manifest**: `manifest.mjs` defining schema, dependencies, defaults, secrets, and hooks
|
||||
- **Python deployment script**: `deploy.py`, a plain pyinfra script
|
||||
|
||||
Configuration variables are declared in the manifest and validated with Zod schemas before the deployment script runs.
|
||||
@@ -96,7 +96,7 @@ apt.packages(
|
||||
)
|
||||
```
|
||||
|
||||
Every key defined in the manifest `schema` is guaranteed to be present on `host.data` — either from the Zod `.default()`, from `.nopyrc.json`, from a dependency, or from a user prompt.
|
||||
Every key defined in the manifest `schema` is guaranteed to be present on `host.data` — either from the Zod `.default()`, from `.nopyrc.json`, from a recorded session, from a dependency, or from a user prompt.
|
||||
|
||||
**Value types**: pyinfra parses `--data` values before your script sees them. `"true"` / `"false"` become booleans, numeric strings become `int`, valid JSON becomes the parsed structure, and everything else stays a string. This is why `UPDATE` can be handed straight to pyinfra's `update=` argument, while `PACKAGES` is wrapped in `str(...)` before splitting.
|
||||
|
||||
@@ -104,18 +104,57 @@ Every key defined in the manifest `schema` is guaranteed to be present on `host.
|
||||
|
||||
Variable defaults are defined directly in the Zod schema using `.default()`. This ensures that every cube has a predictable starting state and provides type-safe default values.
|
||||
|
||||
**Priority order (lowest to highest):**
|
||||
A variable can be set from several places in one run. Every assignment is kept, tagged with where it came from — its **origin** — and the highest-ranked origin wins.
|
||||
|
||||
1. Zod schema `.default()` values
|
||||
2. Global `env` from `.nopyrc.json`
|
||||
3. User prompts, or the recorded answers on session replay
|
||||
4. Variables passed in by a dependency or a hook
|
||||
**Origins, lowest to highest:**
|
||||
|
||||
| Origin | Set by |
|
||||
| --------- | ------------------------------------------------------- |
|
||||
| `default` | the Zod schema's `.default()` |
|
||||
| `env` | the `env` block of `.nopyrc.json` |
|
||||
| `session` | a value recorded in a session file or history entry |
|
||||
| `prompt` | what the user typed |
|
||||
| `param` | a dependency spec or a `before`/`after` hook |
|
||||
|
||||
This allows cubes to ship with reasonable defaults while still allowing users to override them globally via `.nopyrc.json` or interactively during deployment. Because `env` outranks the schema, `.nopyrc.json` is also what steers a run started with `--use-defaults`, which never prompts.
|
||||
|
||||
3 and 4 rarely compete: a key a dependency supplies is left out of the prompt entirely, so the user is only ever asked about the keys nothing else has set.
|
||||
`prompt` and `param` rarely compete: a key a dependency supplies is left out of the prompt entirely, so the user is only ever asked about the keys nothing else has set.
|
||||
|
||||
A field declared without `.default()` has none of sources 1 and 2 to fall back on. It is prompted for like any other, with an empty initial value — but a run that cannot prompt (`--use-defaults`) fails on it unless `env` or a dependency provides it.
|
||||
Ranking by origin rather than by arrival order is what makes replay work: a recorded value is applied *before* the cube would be prompted for, and prompting can still override it, but a `--data` value pushed in by a dependency is never clobbered by a stale recording.
|
||||
|
||||
A field declared without `.default()` has no `default` origin to fall back on. It is prompted for like any other, with an empty initial value — but a run that cannot prompt (`--use-defaults`) fails on it unless `env` or a dependency provides it.
|
||||
|
||||
#### Secrets
|
||||
|
||||
A manifest can name schema keys that hold sensitive values:
|
||||
|
||||
```javascript
|
||||
export default cubes.Manifest({
|
||||
id: 'user:add',
|
||||
name: 'Add a user account',
|
||||
secrets: ['PASSWORD'],
|
||||
schema: z.object({
|
||||
USERNAME: z.string().describe('Username for the new account').default('deploy'),
|
||||
PASSWORD: z.string().describe('Password for the new user account').default('changeme'),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Every entry must be a key of `schema`; naming anything else is a manifest error and aborts the run, so a typo fails loudly instead of silently leaving a value unprotected.
|
||||
|
||||
Declaring a key a secret changes three things:
|
||||
|
||||
- **It is never written to a session file or to the history.** Everything else the run settled on is recorded — including values that came from a `.default()` — but declared secrets are left out.
|
||||
- **It is masked wherever a command or a plan is printed** — `--dry-run`, `--print-only`, and the debug log all show `********` in place of the value, in the variable list *and* in the `pyinfra` command line above it. The SSH password passed via `--password` is masked the same way, whether or not any cube declares secrets.
|
||||
- **It is re-prompted on replay**, since there is nothing recorded to replay from (see [Session Recording and Replay](#session-recording-and-replay)).
|
||||
|
||||
Nopy does not guess. A key called `PASSWORD` in a manifest that declares no `secrets` is treated as an ordinary variable — recorded, and printed in the clear.
|
||||
|
||||
Three limits are worth knowing, because `secrets` keeps a value out of the files nopy writes and nothing more:
|
||||
|
||||
- **It is on the command line.** pyinfra takes its data as `--data KEY=value`, so the real value is visible in `ps` for as long as the deployment runs. Masking covers nopy's own output, not the process table.
|
||||
- **The prompt shows it.** The variable form displays and pre-fills what it is asking about, so a secret is on screen while it is being entered or confirmed.
|
||||
- **A `.default()` is not protected.** A default lives in the manifest, in plain text, wherever the manifest is checked in. Give a secret a placeholder default like `changeme` if it needs one at all, never a real credential.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -125,6 +164,7 @@ Uses `.nopyrc.json` files (project-level or home directory) containing:
|
||||
{
|
||||
"hosts": ["host1.example.com", "host2.example.com"],
|
||||
"cubeDirs": ["./cubes", "../shared-cubes"],
|
||||
"cubePackages": ["@bitsquare/cubes-core"],
|
||||
"env": {
|
||||
"SHARED_VAR": "value"
|
||||
},
|
||||
@@ -144,6 +184,8 @@ Uses `.nopyrc.json` files (project-level or home directory) containing:
|
||||
|
||||
`history` controls automatic session recording (see [Deployment History](#deployment-history)), and `execution.continueOnError` sets the default for `--continue-on-error`.
|
||||
|
||||
`cubeDirs` holds paths, `cubePackages` holds installed npm packages that ship cubes — see [Cube Discovery](#cube-discovery) below and [CUBE-BUNDLES.md](docs/CUBE-BUNDLES.md) for publishing your own. Both are additive, and both resolve relative to the config file that named them, not to the working directory: a `.nopyrc.json` two levels up may name a package that only exists in *its* `node_modules`.
|
||||
|
||||
#### Logging Configuration
|
||||
|
||||
Control pyinfra output verbosity and debug information using the `log` configuration object:
|
||||
@@ -213,12 +255,16 @@ Sessions are stored in `.nopysession.json` files with the following structure:
|
||||
|
||||
**Structure Details:**
|
||||
|
||||
- **`cubes`**: Array of cubes with only cube-specific variables (not global env vars)
|
||||
- **`env`**: Global environment variables shared across cubes (like in `.nopyrc.json`)
|
||||
- **`cubes`**: Array of cubes with the variable values that cube ran with
|
||||
- **`env`**: The `env` block of `.nopyrc.json` as it stood at record time, kept for reference
|
||||
- **`hosts`**: Array of target hosts
|
||||
- **`auth`**: Authentication configuration (passwords are never stored)
|
||||
|
||||
**Security Note**: Passwords are never stored in session files. If a session uses password authentication, you'll be prompted for the password during replay.
|
||||
**What is recorded:** every value each cube settled on, regardless of where it came from — a value the user typed, one inherited from `.nopyrc.json` `env`, one a dependency supplied, and one that fell through to the schema's `.default()` are all written out the same way. A session is therefore a full snapshot rather than a diff, and a `--use-defaults` run produces a session with real values in it instead of an empty one.
|
||||
|
||||
The consequence is that replay is faithful rather than re-derived: the recorded value outranks the current `.nopyrc.json` `env` and the current schema default, so editing either one does not silently change what a replay does. To pick up a new default, record a fresh session.
|
||||
|
||||
**Security Note**: Passwords are never stored in session files. This covers both the SSH password — a session records the auth *method* and username, never the credential — and any schema key a cube's manifest lists under [`secrets`](#secrets). Both are re-prompted on replay.
|
||||
|
||||
#### Recording a Session
|
||||
|
||||
@@ -240,12 +286,48 @@ nopy install --load-session my-deployment.nopysession.json
|
||||
# Only password authentication will prompt for credentials
|
||||
```
|
||||
|
||||
A replay runs straight through without asking anything, with three exceptions. Password authentication always re-prompts. A session with no recorded host falls back to the host picker. And a cube is re-prompted for its declared secrets, plus for any required variable the session has no value for — which happens when the cube's schema has gained a field since the session was written.
|
||||
|
||||
Those re-prompts are what a session cannot supply, so `--use-defaults` cannot paper over them: combining `-D` with a replay that needs either fails with a message naming the keys rather than deploying with a placeholder. Put the values under `env` in `.nopyrc.json` to make such a replay unattended.
|
||||
|
||||
### Cube Discovery
|
||||
|
||||
Nopy searches for cubes in:
|
||||
|
||||
1. Directories specified in `.nopyrc.json` `cubeDirs`
|
||||
2. Directories containing a `.npcubes` marker file (searching upwards from current directory)
|
||||
2. Cube directories of every package listed in `.nopyrc.json` `cubePackages`
|
||||
3. Directories containing a `.npcubes` marker file (searching upwards from current directory)
|
||||
|
||||
All three are unioned and scanned the same way. A directory is a cube when it holds both a manifest (`manifest.mjs` or `*.manifest.mjs`) and a deploy script (`deploy.py` or `*.deploy.py`); dotted directories and `node_modules` are skipped during the scan.
|
||||
|
||||
#### Cube packages
|
||||
|
||||
A cube package is an ordinary npm package that ships cube directories and points at them from its own `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@bitsquare/cubes-core",
|
||||
"nopy": { "cubes": ["./cubes"] }
|
||||
}
|
||||
```
|
||||
|
||||
Install it and name it — nothing needs linking or copying:
|
||||
|
||||
```sh
|
||||
pnpm add -D @bitsquare/cubes-core
|
||||
```
|
||||
|
||||
```json
|
||||
{ "cubePackages": ["@bitsquare/cubes-core"] }
|
||||
```
|
||||
|
||||
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it declares no `nopy.cubes`, or an entry points at a directory that does not exist or lies outside the package.
|
||||
|
||||
#### Ids are claimed globally
|
||||
|
||||
A cube id such as `apt:essentials` is claimed across every source at once, not per directory or per package. Two cubes with the same id abort the run with an error naming both and where each came from. There is no precedence rule and no shadowing — a local cube does not quietly win over a packaged one, in either direction. Prefix your own cubes distinctly if you point `cubeDirs` at a local tree alongside an installed bundle.
|
||||
|
||||
Writing cubes to publish is covered in [CUBE-BUNDLES.md](docs/CUBE-BUNDLES.md).
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
@@ -319,6 +401,17 @@ have no default values. Set them under "env" in .nopyrc.json, pass them from a
|
||||
dependency, or drop --use-defaults to be prompted.
|
||||
```
|
||||
|
||||
Pairing `-D` with a replay fails the same way when the replay would have to ask
|
||||
something — a declared [secret](#secrets), which is never recorded, or a required
|
||||
variable the session has no value for. Both are the sources `-D` has no substitute
|
||||
for, so it stops rather than deploying a placeholder:
|
||||
|
||||
```
|
||||
Error: Cube "user:add" cannot be replayed with --use-defaults: PASSWORD would
|
||||
have to be entered. Secrets are never recorded in a session. Replay without
|
||||
--use-defaults, or set the values under "env" in .nopyrc.json.
|
||||
```
|
||||
|
||||
**Use SSH key authentication**:
|
||||
|
||||
```bash
|
||||
@@ -434,20 +527,20 @@ Session History:
|
||||
Total: 2 session(s)
|
||||
```
|
||||
|
||||
Each entry records the selected cubes together with the variable values that were answered at the prompts, the target hosts, the authentication method, and the username — never the password. Pass an ID to `-H` to run that exact combination again:
|
||||
Each entry records the selected cubes together with every variable value they ran with, the target hosts, the authentication method, and the username — never the password, and never a key the manifest declared a [secret](#secrets). Pass an ID to `-H` to run that exact combination again:
|
||||
|
||||
```bash
|
||||
nopy install -H mdk0zzp8b71cq
|
||||
```
|
||||
|
||||
A replay is non-interactive: cube selection, host, and variable values all come from the entry, so nopy runs straight through without asking anything. The two exceptions are password authentication, which always re-prompts, and an entry with no recorded host, which falls back to the host picker.
|
||||
A replay is non-interactive: cube selection, host, and variable values all come from the entry, so nopy runs straight through without asking anything. It asks only for what the entry cannot hold — the password under password authentication, and any declared secret — plus the host picker when the entry recorded none.
|
||||
|
||||
Two things are worth knowing before relying on an older entry:
|
||||
|
||||
- **Recorded values are applied as defaults, not as a frozen snapshot.** If a cube's schema has gained a variable since the run was recorded, the replay neither prompts for it nor fails — the new variable quietly takes its Zod `.default()`. Global `env` values are likewise read from the *current* `.nopyrc.json` rather than from the entry.
|
||||
- **Recorded values win over the current configuration.** The entry is a snapshot of everything the run settled on, so editing a cube's `.default()` or the `env` block of `.nopyrc.json` afterwards does not change what the replay does. A variable the schema has gained *since* the entry was written has nothing recorded: if it has a `.default()` the replay quietly takes it, and if it is required the replay prompts for it.
|
||||
- **A replay fails if a cube no longer exists.** Renaming or deleting a cube id makes every history entry that referenced it unreplayable: nopy logs `Cube from session not found` and then aborts with `Cube not found: <id>`.
|
||||
|
||||
The history lives in `.nopy.history.json` in the working directory and uses the same structure as a session file, so trimming the array by hand is a perfectly good way to prune it. It does contain the variable values that were entered, which is why it is listed in this repository's `.gitignore` — treat it like any other file holding deployment configuration. A corrupt or unreadable history file is treated as empty rather than raising an error, which looks exactly like a project that has never been deployed from.
|
||||
The history lives in `.nopy.history.json` in the working directory and uses the same structure as a session file, so trimming the array by hand is a perfectly good way to prune it. It does contain the variable values a run used, which is why it is listed in this repository's `.gitignore` — treat it like any other file holding deployment configuration. A corrupt or unreadable history file is treated as empty rather than raising an error, which looks exactly like a project that has never been deployed from.
|
||||
|
||||
For a run you want to keep indefinitely, don't rely on history — it rotates. Use `--save-session` to write it to a file you control (see [Session Recording and Replay](#session-recording-and-replay)).
|
||||
|
||||
@@ -468,7 +561,9 @@ npm run debug
|
||||
## Documentation
|
||||
|
||||
- [Cube Hooks](docs/HOOKS.md) - Lifecycle hooks for dynamic orchestration
|
||||
- [Cube Bundles](docs/CUBE-BUNDLES.md) - Distributing cubes as npm packages
|
||||
- [Session Format](docs/SESSION_FORMAT.md) - Internal JSON/MJS session structure
|
||||
- [API Reference](docs/API.md) - Types and exported functions
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
@@ -65,6 +65,20 @@ interface NopyResult {
|
||||
|
||||
The cubes module provides types and functions for working with deployment units.
|
||||
|
||||
The authoring half of it — `Manifest`, `Cube`, `Hook`, `uniqid` and the rest —
|
||||
actually lives in **[`@bitsquare/nopy-cube`](../../nopy-cube)**, a package with
|
||||
no CLI and no dependency other than zod. `@bitsquare/nopy` re-exports all of it,
|
||||
so both of these work:
|
||||
|
||||
```javascript
|
||||
import { Manifest } from '@bitsquare/nopy-cube'; // in a manifest.mjs — prefer this
|
||||
import { cubes } from '@bitsquare/nopy'; // cubes.Manifest — still supported
|
||||
```
|
||||
|
||||
Import from `nopy-cube` in a cube bundle you intend to publish: it lets the
|
||||
bundle depend on the authoring types without pulling the whole CLI in as a
|
||||
dependency. See [CUBE-BUNDLES.md](CUBE-BUNDLES.md).
|
||||
|
||||
### Types
|
||||
|
||||
#### `Cube<Schema>`
|
||||
@@ -76,6 +90,7 @@ interface Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
key: string; // Unique identifier
|
||||
name: string; // Human-readable name
|
||||
dir: string; // Absolute path to cube directory
|
||||
source: CubeSource; // Where it was discovered
|
||||
dependencies: string[];
|
||||
schema: Schema;
|
||||
defaults: () => z.infer<Schema>;
|
||||
@@ -84,6 +99,18 @@ interface Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
}
|
||||
```
|
||||
|
||||
#### `CubeSource`
|
||||
|
||||
Where a cube came from. Carried so that a duplicate-id error can name the origin
|
||||
of each claimant, which is the difference between a usable error message and a
|
||||
puzzle when the collision is between a local tree and an installed bundle.
|
||||
|
||||
```typescript
|
||||
type CubeSource =
|
||||
| { type: 'dir'; dir: string }
|
||||
| { type: 'package'; packageName: string; dir: string };
|
||||
```
|
||||
|
||||
#### `Manifest<Schema>`
|
||||
|
||||
Cube manifest (used in `manifest.mjs` files).
|
||||
@@ -124,7 +151,9 @@ interface HookContext {
|
||||
|
||||
#### `loadCubes()`
|
||||
|
||||
Loads all cubes from discovered cube directories.
|
||||
Loads all cubes from discovered cube directories — `cubeDirs`, the directories
|
||||
declared by every package in `cubePackages`, and any ancestor directory holding a
|
||||
`.npcubes` marker.
|
||||
|
||||
```typescript
|
||||
const { cubes, errors } = await loadCubes();
|
||||
@@ -139,6 +168,27 @@ interface LoadResult {
|
||||
}
|
||||
```
|
||||
|
||||
`errors` is non-empty for a duplicate id, a manifest that fails to load, a
|
||||
package in `cubePackages` that is not installed or declares no cubes, and a
|
||||
`nopy.cubes` entry that is missing or points outside its package. Any of them
|
||||
aborts the run — none is a silent skip.
|
||||
|
||||
#### `resolveCubePackages(refs)`
|
||||
|
||||
Resolves `CubePackageRef[]` to installed packages and their cube directories.
|
||||
Called by `loadCubes()`; exported because the resolution failures are worth
|
||||
testing on their own.
|
||||
|
||||
```typescript
|
||||
const { packages, errors } = resolveCubePackages(config.cubePackages);
|
||||
|
||||
interface CubePackage {
|
||||
name: string; // the name it was requested under
|
||||
root: string; // absolute path to the package root
|
||||
dirs: string[]; // absolute paths from its `nopy.cubes` field
|
||||
}
|
||||
```
|
||||
|
||||
#### `resolveDependencies(cubes, selectedCubeNames)`
|
||||
|
||||
Resolves all transitive dependencies for selected cubes.
|
||||
@@ -452,11 +502,31 @@ Configuration file structure.
|
||||
interface NopyConfig {
|
||||
hosts: string[];
|
||||
cubeDirs: string[];
|
||||
cubePackages: CubePackageRef[];
|
||||
env: EnvConfig;
|
||||
log?: LogConfig;
|
||||
}
|
||||
```
|
||||
|
||||
#### `CubePackageRef`
|
||||
|
||||
A package named in `cubePackages`, paired with where it was named. In the config
|
||||
file an entry is just a string (`"@bitsquare/cubes-core"`); `loadConfig()`
|
||||
normalises it.
|
||||
|
||||
```typescript
|
||||
interface CubePackageRef {
|
||||
/** The package name, as written in the config. */
|
||||
spec: string;
|
||||
/** Directory of the config file that named it — resolution starts here. */
|
||||
from: string;
|
||||
}
|
||||
```
|
||||
|
||||
`from` is what makes a package named in a parent config resolve against *that*
|
||||
config's `node_modules`, not the working directory's. It is the same problem
|
||||
`PATH_PROPERTIES` solves for relative `cubeDirs`.
|
||||
|
||||
#### `LogConfig`
|
||||
|
||||
Logging configuration.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# Cube bundles
|
||||
|
||||
How to package cubes as an npm package so other projects can install them, and
|
||||
what changes once a cube lives in `node_modules` instead of in your own tree.
|
||||
|
||||
If you only want to *use* a published bundle, you need one line of config:
|
||||
|
||||
```json
|
||||
{ "cubePackages": ["@bitsquare/cubes-core"] }
|
||||
```
|
||||
|
||||
The rest of this document is for writing one.
|
||||
|
||||
- [What a bundle is](#what-a-bundle-is)
|
||||
- [The package manifest](#the-package-manifest)
|
||||
- [Writing the cubes](#writing-the-cubes)
|
||||
- [Ids are claimed globally](#ids-are-claimed-globally)
|
||||
- [An installed bundle is read-only](#an-installed-bundle-is-read-only)
|
||||
- [How resolution actually works](#how-resolution-actually-works)
|
||||
- [Publishing](#publishing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## What a bundle is
|
||||
|
||||
An ordinary npm package that ships cube directories and points at them from its
|
||||
own `package.json`. There is no build step, no plugin API and no entry point —
|
||||
nopy reads the directories off disk and imports each `manifest.mjs` directly.
|
||||
|
||||
```
|
||||
@acme/cubes-web
|
||||
├── package.json nopy.cubes → ["./cubes"]
|
||||
├── README.md
|
||||
└── cubes/
|
||||
├── nginx/
|
||||
│ ├── manifest.mjs
|
||||
│ └── deploy.py
|
||||
└── certbot/
|
||||
├── manifest.mjs
|
||||
└── deploy.py
|
||||
```
|
||||
|
||||
`@bitsquare/cubes-core` in this repository is the worked example, and is consumed
|
||||
by this repository through exactly the mechanism described here — it is not
|
||||
special-cased.
|
||||
|
||||
## The package manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/cubes-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"nopy": { "cubes": ["./cubes"] },
|
||||
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
|
||||
"publishConfig": { "access": "public" },
|
||||
"dependencies": {
|
||||
"@bitsquare/nopy-cube": "^1.0.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
|
||||
relative to the package root, each scanned recursively for cubes. Several
|
||||
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
|
||||
must stay inside the package — a path escaping the root is refused, not resolved.
|
||||
|
||||
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
|
||||
loads (the extension carries the day), but anything it imports relatively will
|
||||
not behave the way you expect.
|
||||
|
||||
**`files`** decides the tarball. Note the negation: a cube that has been run
|
||||
leaves a `pyinfra-debug.log` next to its `deploy.py`, and `.gitignore` has no
|
||||
effect on what npm packs. Check with `npm pack --dry-run` before publishing.
|
||||
|
||||
**Dependencies** are `@bitsquare/nopy-cube` and `zod`, both real dependencies
|
||||
rather than peers — a bundle is a leaf, and the copies it gets are the copies its
|
||||
manifests use. Do **not** depend on `@bitsquare/nopy`: the CLI is what installs
|
||||
your bundle, not the other way round, and depending on it invites two copies of
|
||||
the same code into one tree.
|
||||
|
||||
## Writing the cubes
|
||||
|
||||
A cube directory holds a manifest (`manifest.mjs` or `*.manifest.mjs`) and a
|
||||
deploy script (`deploy.py` or `*.deploy.py`). Anything else in the directory is
|
||||
invisible to the loader but readable from the script, which runs with the cube
|
||||
directory as its working directory.
|
||||
|
||||
```javascript
|
||||
// cubes/nginx/manifest.mjs
|
||||
import { Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'web:nginx',
|
||||
name: 'Install and configure nginx',
|
||||
dependencies: () => ['apt:essentials'],
|
||||
secrets: ['TLS_KEY'],
|
||||
schema: z.object({
|
||||
SERVER_NAME: z.string().describe('Server name').default('example.com'),
|
||||
TLS_KEY: z.string().describe('TLS private key (PEM)').default(''),
|
||||
HTTP2: z.boolean().describe('Enable HTTP/2').default(true),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```python
|
||||
# cubes/nginx/deploy.py
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, files
|
||||
|
||||
SERVER_NAME = host.data.SERVER_NAME
|
||||
|
||||
apt.packages(name='Install nginx', packages=['nginx'], _sudo=True)
|
||||
```
|
||||
|
||||
Import **`@bitsquare/nopy-cube`**, not `@bitsquare/nopy`. It is types and a
|
||||
factory with zod as its only peer — no CLI, no prompts, no process spawning — so
|
||||
your bundle stays a leaf. (`@bitsquare/nopy` re-exports the same surface as
|
||||
`cubes.Manifest`, which is what older manifests use. It still works; it just
|
||||
drags the CLI into your dependency graph if you declare it.)
|
||||
|
||||
Four things the schema is load-bearing for:
|
||||
|
||||
- **`.describe()` is the prompt label.** A field without one prompts with its raw
|
||||
key.
|
||||
- **`.default()` makes the field optional.** A field with no default is required,
|
||||
and is re-prompted on replay if a session has no value for it.
|
||||
- **Every schema key reaches pyinfra** as `--data KEY=value`, so `host.data.KEY`
|
||||
is always defined. pyinfra parses the values itself: `"true"` arrives as a
|
||||
bool, `"8080"` as an int.
|
||||
- **`secrets` names keys whose values must not be persisted.** They are excluded
|
||||
from session files and history, masked wherever a command is printed, and
|
||||
re-prompted on replay. Naming a key that is not in the schema is a load error.
|
||||
A secret is still visible in `ps` while pyinfra runs — masking covers nopy's
|
||||
own output, not the process table — so treat it as protection against writing
|
||||
credentials to disk, not as protection against a shared host.
|
||||
|
||||
`dependencies` is a function of the *collected* variables, so it can branch on
|
||||
what the user actually answered, and it may pass parameters:
|
||||
|
||||
```javascript
|
||||
dependencies: (v) => (v.HTTP2 ? ['apt:essentials', ['web:tls', { MODE: 'strict' }]] : []),
|
||||
```
|
||||
|
||||
`before` / `after` hooks get a context whose `exec(id, vars)` pulls in any cube
|
||||
by id, declared dependency or not. See [HOOKS.md](HOOKS.md).
|
||||
|
||||
## Ids are claimed globally
|
||||
|
||||
An id is claimed across every source at once — `cubeDirs`, `.npcubes` trees and
|
||||
every installed bundle share one flat namespace. Two cubes claiming the same id
|
||||
abort the run with an error naming both and where each came from.
|
||||
|
||||
There is no precedence and no shadowing, deliberately, in either direction: a
|
||||
local cube does not quietly win over a packaged one, and installing a second
|
||||
bundle cannot silently change what an existing id deploys. Overriding a cube from
|
||||
a bundle is not a supported operation; fork the cube under your own id instead.
|
||||
|
||||
So prefix distinctly. `@acme/cubes-web` claiming `nginx` is asking for trouble the
|
||||
first time someone installs a second bundle; `web:nginx` is not. Ids need not
|
||||
mirror the directory layout — `cubes/network/tailscale` declares `net:tailscale`
|
||||
— so the prefix is free.
|
||||
|
||||
An id is also the session key. Renaming one silently invalidates every recorded
|
||||
session that used it, so treat a rename as a breaking change of the bundle.
|
||||
|
||||
## An installed bundle is read-only
|
||||
|
||||
Under pnpm, installed files are **hardlinked into a global store shared by every
|
||||
project on the machine**. A cube that writes next to its own `deploy.py` does not
|
||||
just dirty one `node_modules` — it corrupts that store for every other project.
|
||||
|
||||
Write to `/tmp`, to a path the user configured, or to the remote host. Never to
|
||||
the cube's own directory. Files the cube needs to *read* (templates, config
|
||||
fragments, systemd units) are fine and are exactly what the cube directory is for
|
||||
— `deploy.py` runs with it as the working directory, so `files.template('nginx.conf.j2', ...)`
|
||||
resolves.
|
||||
|
||||
This is the one constraint that does not exist while the cubes live in your own
|
||||
repo, which makes it the one most likely to be discovered late. Test against an
|
||||
installed copy, not a linked one.
|
||||
|
||||
## How resolution actually works
|
||||
|
||||
Worth knowing, because two of the failure modes are otherwise baffling.
|
||||
|
||||
**Where a package is looked up from.** Each `cubePackages` entry is resolved from
|
||||
the directory of the config file that named it, not from the working directory.
|
||||
Configs merge upward, so a `.nopyrc.json` two levels up can name a bundle that
|
||||
only exists in *its* `node_modules`, and it resolves. The lookup reads
|
||||
`package.json` off disk via `createRequire(...).resolve.paths()` rather than
|
||||
going through `exports` — a bundle ships directories and has no entry point to
|
||||
declare.
|
||||
|
||||
**Why the loader does not simply scan `node_modules`.** It cannot: pnpm plants a
|
||||
symlink at `node_modules/<name>`, and `readdir` reports it as a symlink, not a
|
||||
directory, so a recursive scan skips every package silently. Naming packages
|
||||
explicitly is the fix, and it is also the reason `node_modules` is skipped during
|
||||
the cube scan itself.
|
||||
|
||||
**How a manifest finds its imports.** Ordinary Node resolution, from the
|
||||
manifest's own directory. An installed bundle has its own `node_modules` with
|
||||
`@bitsquare/nopy-cube` and `zod` in it, so this just works. A hand-written cube
|
||||
sitting in a directory with no `node_modules` would historically fail with
|
||||
`ERR_MODULE_NOT_FOUND`; nopy now registers a resolve hook that catches exactly
|
||||
that case and falls back to resolving `@bitsquare/nopy-cube`, `@bitsquare/nopy`
|
||||
and `zod` from the running CLI. Normal resolution is always tried first, so a
|
||||
cube that ships its own zod keeps it. Treat the hook as a convenience for local
|
||||
cubes — a published bundle must declare its dependencies properly.
|
||||
|
||||
**Two copies of zod is a real hazard.** `instanceof` comparisons fail across
|
||||
copies, which is why nopy inspects schemas structurally (`schema.def.type`) and
|
||||
why `secrets` is a plain array rather than `.meta()` metadata — zod's metadata
|
||||
registry is per-copy, and a marker written into one copy's registry is invisible
|
||||
to another. Keep your zod range compatible with the CLI's (`^4.4.3`) and the
|
||||
package manager will usually give you one copy.
|
||||
|
||||
## Publishing
|
||||
|
||||
Nothing bundle-specific: `npm publish` (or `pnpm publish`) with a version bump.
|
||||
Some things worth deciding once:
|
||||
|
||||
- **Version the bundle independently of nopy.** There is no compatibility check
|
||||
between the two — the loader reads whatever `nopy.cubes` points at. Document
|
||||
the nopy version you test against in your README.
|
||||
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
|
||||
and breaks any manifest listing it as a dependency, including manifests in
|
||||
other people's bundles.
|
||||
- **Changing a schema key is breaking** in the same way; adding one with a
|
||||
`.default()` is not.
|
||||
- **Test the installed shape, not the linked one.** `npm pack`, install the
|
||||
tarball into a throwaway directory with a `.nopyrc.json` naming it, and deploy
|
||||
from it. This is what catches a missing file, a cube that writes to its own
|
||||
directory, and an undeclared dependency — none of which show up while the
|
||||
package is symlinked into the repo that wrote it.
|
||||
|
||||
For an unattended check, replay a session file rather than reaching for `-P`
|
||||
alone, which still opens the interactive picker:
|
||||
|
||||
```sh
|
||||
nopy install -l session.json -P -D
|
||||
```
|
||||
|
||||
Note that a replay re-prompts for anything a manifest lists in `secrets` —
|
||||
those are never written to a session — so pick a cube without them, or put the
|
||||
values under `env` in `.nopyrc.json`.
|
||||
|
||||
For how this repository releases its own packages, see
|
||||
[README.PUBLISH.md](../../../README.PUBLISH.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| `Cube package 'X' is not installed (looked up from …)` | Not installed, or installed somewhere other than the config that named it. The path in the message is where the lookup started. |
|
||||
| `Cube package 'X' declares no cubes` | Missing or malformed `nopy.cubes` in the package's `package.json`. It must be a non-empty array of strings. |
|
||||
| `'./cubes' does not exist in …` | The directory was not packed. Check `files` and `npm pack --dry-run`. |
|
||||
| `'…' points outside the package` | A `nopy.cubes` entry escaping the package root. Not allowed. |
|
||||
| `Duplicate cube id 'X' from N sources:` | Two or more cubes claiming one id; the message lists each source. Rename one — there is no precedence rule to lean on. |
|
||||
| `ERR_MODULE_NOT_FOUND` for `zod` or `@bitsquare/nopy-cube` | The bundle did not declare them as dependencies. The resolve-hook fallback covers loose local cubes, not published packages. |
|
||||
| `Invalid manifest in …: 'secrets' names X, which is not in the schema` | A `secrets` entry with no matching schema key — usually a typo or a renamed field. |
|
||||
| Cubes work linked, fail installed | Almost always a write into the cube's own directory, or a file missing from `files`. |
|
||||
@@ -1,6 +1,11 @@
|
||||
# Cube bundles as npm packages
|
||||
|
||||
Status: **Phase 0 has landed; Phases 1–6 are still a plan, not a record.**
|
||||
Status: **All six phases have landed. This document is now a record, not a plan.**
|
||||
The one thing still unproven is the publish lane against a real registry — see
|
||||
*Risks*.
|
||||
`cubePackages` resolves and loads end to end, `@bitsquare/nopy-cube` exists and
|
||||
the publish lane can ship a linked package. What is missing is a bundle to point
|
||||
`cubePackages` at.
|
||||
|
||||
Distributing cubes as npm packages so a project can `pnpm add @acme/cubes-net`
|
||||
and have its cubes show up in `nopy` alongside local ones.
|
||||
@@ -166,7 +171,7 @@ Rules:
|
||||
- A bundle must not ship a `.nopyrc.json`. Config discovery walks up from
|
||||
`process.cwd()`, never from cube directories, so it would never be read.
|
||||
|
||||
## Phase 2 — resolution
|
||||
## Phase 2 — resolution — **done**
|
||||
|
||||
### Config surface
|
||||
|
||||
@@ -271,7 +276,7 @@ it is exported from `src/cubes/index.ts` and covered by tests. The
|
||||
`node_modules` skip inside `scanDirectory` stays and is now *correct*: a
|
||||
bundle's own `node_modules` should not be scanned.
|
||||
|
||||
## Phase 3 — hard errors with attribution
|
||||
## Phase 3 — hard errors with attribution — **done**
|
||||
|
||||
`Cube` gains a source, as an optional fourth constructor parameter so the public
|
||||
signature stays backwards compatible:
|
||||
@@ -306,7 +311,7 @@ claim the same id they are mutually exclusive, and the fix is upstream.
|
||||
Surface the source in the interactive picker and in `--json` output so a user can
|
||||
see where a cube came from before running it.
|
||||
|
||||
## Phase 4 — `@bitsquare/nopy-cube`, the authoring package
|
||||
## Phase 4 — `@bitsquare/nopy-cube`, the authoring package — **done**
|
||||
|
||||
The problem: a manifest does `import { cubes } from '@bitsquare/nopy'`, resolved
|
||||
by ordinary Node resolution from the manifest's own directory. From inside
|
||||
@@ -350,20 +355,33 @@ already coverage-excluded barrels — so `import { cubes } from '@bitsquare/nopy
|
||||
in every existing manifest keeps working unchanged. Nothing in `cubes/` has to be
|
||||
touched at migration time.
|
||||
|
||||
Repo plumbing this requires:
|
||||
`cubes/types.ts` and `cubes/factories.ts` moved wholesale, with
|
||||
`tests/cubes.types.test.ts` and `tests/cubes.factories.test.ts` behind them.
|
||||
`tests/helpers/foreign-zod.ts` is duplicated rather than shared — fifteen lines,
|
||||
and the alternative is a test-only dependency edge between the packages.
|
||||
|
||||
- `tsconfig.base.json`: add `"@bitsquare/nopy-cube": ["./packages/nopy-cube/src"]`
|
||||
to `paths`.
|
||||
- Root `tsconfig.json`: add the project reference.
|
||||
- `packages/nopy/tsconfig.json`: `references` is currently `[]` — add
|
||||
`{ "path": "../nopy-cube" }`. This is the first reference edge in the repo, so
|
||||
`tsc --build` ordering starts mattering.
|
||||
Repo plumbing it took:
|
||||
|
||||
- `tsconfig.base.json`: `"@bitsquare/nopy-cube": ["./packages/nopy-cube/src"]`.
|
||||
- Root `tsconfig.json` and `packages/nopy/tsconfig.json`: the project reference.
|
||||
This is the first reference edge in the repo, and it broke the gate
|
||||
immediately: **`tsc --build --noEmit` is not legal once a project has
|
||||
references** — TS6310, "referenced project may not disable emit", because a
|
||||
composite project has to emit the declarations its dependents read. The root
|
||||
`typecheck` script is now plain `tsc --build`. It still fails on a type error,
|
||||
and it now also proves the build works; the cost is that it writes `dist`,
|
||||
which is gitignored.
|
||||
- `packages/nopy/package.json`: `"@bitsquare/nopy-cube": "workspace:*"`.
|
||||
- A `vitest.config.ts` for the new package with the same thresholds. `Manifest()`,
|
||||
`Manifest.create()` and `Cube.getDefaults()` all carry logic, so the relevant
|
||||
cases move over from `tests/cubes.factories.test.ts`.
|
||||
- `packages/nopy/vitest.config.ts`: a `resolve.alias` for `@bitsquare/nopy-cube`
|
||||
pointing at `../nopy-cube/src/index.ts`. Without it the workspace link
|
||||
resolves through `exports` to `dist`, so `pnpm test` on a clean checkout would
|
||||
fail until something had built it, and a stale `dist` would silently be what
|
||||
the tests ran against. The same config excludes `**/nopy-cube/**` from
|
||||
coverage — the aliased files were being counted against nopy's thresholds.
|
||||
- A `vitest.config.ts` for the new package with the same thresholds. It sits at
|
||||
100 % statements/functions/lines, 91 % branches.
|
||||
|
||||
### The release lane needs fixing first
|
||||
### The release lane needed fixing first
|
||||
|
||||
This is the part that is easy to miss. `link-workspace-packages` is unset and
|
||||
pnpm 10+ defaults it to `false`, so a plain semver range would resolve
|
||||
@@ -389,10 +407,40 @@ Pick one before publishing anything:
|
||||
passes — compute every snapshot version first, then publish — so `nopy` can pin
|
||||
the exact `nopy-cube` snapshot from the same run.
|
||||
|
||||
Recommendation: `pnpm publish`, and verify against the Gitea registry with a
|
||||
**Measured, both directions.** `npm pack` in `packages/nopy` produces a tarball
|
||||
whose manifest still reads `"@bitsquare/nopy-cube": "workspace:*"`; `pnpm pack`
|
||||
produces one that reads `"1.0.0-alpha0"`. So the failure was real and the fix
|
||||
works.
|
||||
|
||||
Went with `pnpm publish --ignore-scripts --no-git-checks` in both workflows.
|
||||
`--no-git-checks` is not optional in either: `release.yml` runs on a detached
|
||||
HEAD, and `publish-snapshot.yml` dirties the tree by stamping versions.
|
||||
(`pnpm pack` has no `--ignore-scripts`, only `pnpm publish` does.)
|
||||
|
||||
Three small scripts carry the parts that are easy to get wrong, all runnable
|
||||
locally:
|
||||
|
||||
- **`scripts/verify-pack.mjs`** — packs every publishable package and fails if a
|
||||
`workspace:` range survived into the tarball. Runs between build and publish
|
||||
in both workflows. Turns "npm would have shipped a broken manifest" from an
|
||||
install-time surprise into a red run.
|
||||
- **`scripts/publish-order.mjs`** — topologically sorts the publishable packages.
|
||||
`packages/*/` alphabetically puts `nopy` ahead of the `nopy-cube` it depends
|
||||
on; the snapshot workflow now iterates this instead.
|
||||
- **`scripts/linked-deps.mjs`** — lists a package's workspace links as
|
||||
`<name> <version>`, resolved by package name rather than by directory.
|
||||
`release.yml` uses it to refuse a release whose linked dependency is not on
|
||||
npmjs yet, which is the one mistake that cannot be taken back after 72 hours.
|
||||
|
||||
`publish-snapshot.yml` also became two passes over the packages: stamp every
|
||||
version first, then publish. `pnpm publish` substitutes the version the linked
|
||||
package declares *at pack time*, so `nopy-cube` has to be carrying its snapshot
|
||||
version before `nopy` is packed.
|
||||
|
||||
Still unverified: none of this has run against the Gitea registry. Worth a
|
||||
throwaway version before the first real release.
|
||||
|
||||
### Also: the resolve hook
|
||||
### Also: the resolve hook — built
|
||||
|
||||
Independent of the split, and worth building anyway — it retires the
|
||||
`ERR_MODULE_NOT_FOUND` gotcha CLAUDE.md documents for the local `cubes/` tree,
|
||||
@@ -414,33 +462,48 @@ Falling back for `zod` hands local cubes the *CLI's* zod instance, so no
|
||||
duplication arises there. Bundles are the case that duplicates it, and Phase 0.4
|
||||
is what makes that safe.
|
||||
|
||||
New `packages/nopy/src/nopy.resolve-hook.mjs`, registered once from `loadCubes()`
|
||||
`packages/nopy/src/cubes/resolve-hook.mjs`, registered once from `loadCubes()`
|
||||
before the first `import(manifestPath)`:
|
||||
|
||||
```ts
|
||||
module.register('./nopy.resolve-hook.mjs', import.meta.url, {
|
||||
data: { fallback: import.meta.resolve('./index.js') },
|
||||
});
|
||||
module.register('./resolve-hook.mjs', import.meta.url, { data: { from: import.meta.url } });
|
||||
```
|
||||
|
||||
The hook tries `next(specifier, ctx)` **first** and only falls back to the
|
||||
running CLI's own copy on failure. That ordering matters: a consumer that has its
|
||||
own `@bitsquare/nopy` installed keeps using it, so the hook never silently
|
||||
introduces version skew.
|
||||
`from` is a URL inside the running CLI's own package; the hook thread builds a
|
||||
`createRequire` from it and resolves the fallbacks out of the CLI's own
|
||||
dependencies.
|
||||
|
||||
Constraints:
|
||||
The hook tries `next(specifier, ctx)` **first** and only falls back on failure.
|
||||
That ordering matters: a consumer that has its own copy installed keeps using
|
||||
it, so the hook never silently introduces version skew. There is a test for
|
||||
exactly that — a stub `zod` beside the cube wins over the CLI's real one.
|
||||
|
||||
- `module.register()` is process-global and cannot be undone. Install it once,
|
||||
behind a module-level guard.
|
||||
Constraints, as built:
|
||||
|
||||
- `module.register()` is process-global and cannot be undone. Installed once,
|
||||
behind a module-level guard, and wrapped in a `try` — the hook is a
|
||||
convenience, so a registration failure must not abort a run.
|
||||
- The hook file runs on a separate thread; the `data` payload must be
|
||||
structured-cloneable (a string URL is).
|
||||
- The `.mjs` must ship in `dist` and be listed in `files` — it already is, via
|
||||
the `dist` entry.
|
||||
- It resolves `@bitsquare/nopy` and `zod`, not `@bitsquare/nopy-cube`. Bundles
|
||||
never depend on the hook; only the in-repo `cubes/` tree and hand-written local
|
||||
cubes do.
|
||||
- The `.mjs` has to reach `dist`, and `tsc` does not copy it: nopy's `build` is
|
||||
now `tsc && cp src/cubes/*.mjs dist/cubes/`. `files` already covers it via the
|
||||
`dist` entry.
|
||||
- It covers **three** specifiers, not the two the plan named: `zod`,
|
||||
`@bitsquare/nopy`, and `@bitsquare/nopy-cube` — a hand-written local cube is
|
||||
as entitled to the new authoring package as to the old one. Subpaths count
|
||||
(`@bitsquare/nopy/package.json`), anything else stays a hard failure.
|
||||
|
||||
## Phase 5 — proof of concept: `packages/cubes-core`
|
||||
**The tests have to spawn a real `node`.** Written inside the vitest worker they
|
||||
pass whether or not the hook is installed: vite resolves the dynamic import
|
||||
itself and finds `zod` from the project root. `tests/cubes.resolve-hook.test.ts`
|
||||
therefore runs each case in a child process, and the first case asserts the
|
||||
*failure* without the hook so the rest cannot silently stop proving anything.
|
||||
|
||||
**Verified end to end.** From a plain `node` at the repo root, with nothing
|
||||
linked, the built loader reads all 22 cubes under `cubes/` with zero errors. The
|
||||
`ERR_MODULE_NOT_FOUND` gotcha in `CLAUDE.md` is retired.
|
||||
|
||||
## Phase 5 — proof of concept: `packages/cubes-core` — **done**
|
||||
|
||||
Depends on Phase 4 shipping first — the bundle cannot declare
|
||||
`@bitsquare/nopy-cube` as a dependency until it exists, and the publish-lane fix
|
||||
@@ -478,19 +541,50 @@ has to be in place before either package is published.
|
||||
`nopy-cube` references from Phase 4 are separate.)
|
||||
8. Biome already lints `cubes/**/*.mjs` from the root; only the path changes.
|
||||
|
||||
### Verifying the PoC
|
||||
### What differed from the plan
|
||||
|
||||
- **In-workspace:** `pnpm --filter @bitsquare/nopy run nopy -P` from the repo
|
||||
root lists `net:tailscale`, `apt:install`, … and prints deploy commands whose
|
||||
`--chdir` points into `node_modules/@bitsquare/cubes-core/cubes/…`.
|
||||
- **Out-of-workspace (the real test):** `npm pack` the bundle, install the
|
||||
tarball into a throwaway directory with a `.nopyrc.json` naming it, install
|
||||
`nopy` *globally*, and run `nopy -P`. This is what actually exercises Phase 4 —
|
||||
a manifest resolving its import from a `node_modules` tree that has no
|
||||
`@bitsquare/nopy` in it. Check the installed tarball's `package.json` really
|
||||
carries a concrete `@bitsquare/nopy-cube` range and not `workspace:*`.
|
||||
- **Step 2's optional migration was done.** All 22 manifests now import
|
||||
`{ Manifest }` from `@bitsquare/nopy-cube`, not `{ cubes }` from
|
||||
`@bitsquare/nopy`. Optional for correctness, but it is the only version of the
|
||||
PoC that proves anything: leaving the old import in place would have resolved
|
||||
through the CLI that happens to sit in the same tree.
|
||||
- **`uniqid` had to move too.** Two manifests use it (`admin:hostname` bare,
|
||||
`user:add` via `cubes.uniqid`), so `src/cubes/utils.ts` and its test went to
|
||||
`nopy-cube` alongside `types.ts`, and `uniqid` joined the authoring barrel.
|
||||
Otherwise one migrated manifest would still have been importing the CLI.
|
||||
- **`files` needs a log exclusion.** Cubes that have been run leave a gitignored
|
||||
`pyinfra-debug.log` next to `deploy.py`; gitignore does not filter an npm
|
||||
tarball. `"files": ["cubes", "!cubes/**/*.log", …]` does. Verified: 22
|
||||
manifests, 22 deploy scripts, 0 logs in the packed artefact.
|
||||
- **`verify-pack.mjs` picks the bundle up for free** — it walks every non-private
|
||||
`packages/*`, so `cubes-core`'s `workspace:*` edge is checked like nopy's.
|
||||
|
||||
## Phase 6 — documentation
|
||||
### Verifying the PoC — done
|
||||
|
||||
- **In-workspace:** the built loader, run from the repo root against the new
|
||||
root `.nopyrc.json`, reads 22 cubes with 0 errors and reports
|
||||
`source: { type: 'package', packageName: '@bitsquare/cubes-core', dir:
|
||||
'…/node_modules/@bitsquare/cubes-core/cubes' }` — the pnpm symlink path, not a
|
||||
plain directory.
|
||||
- **Out-of-workspace (the real test):** `pnpm pack` for `nopy-cube`, `nopy` and
|
||||
`cubes-core`, then **`npm install`** of all three tarballs into a throwaway
|
||||
directory with a `.nopyrc.json` naming only the bundle. npm is the strict test
|
||||
here — it does not understand `workspace:`, so a leaked range fails the install
|
||||
outright. It installed clean, and the installed
|
||||
`@bitsquare/nopy/package.json` carries `"@bitsquare/nopy-cube":
|
||||
"1.0.0-alpha0"`. `nopy install -l session.json -P -D` then resolved
|
||||
`apt:essentials` and printed a `--chdir` into
|
||||
`node_modules/@bitsquare/cubes-core/cubes/apt/essentials`. Since the loader
|
||||
aborts on any manifest error and this run did not, all 22 manifests imported
|
||||
`@bitsquare/nopy-cube` and `zod` successfully from a tree containing no
|
||||
workspace links.
|
||||
|
||||
Note for anyone repeating this: `-P` on its own is interactive, and a replay
|
||||
still prompts for anything a manifest declares in `secrets` (they are never
|
||||
persisted to a session) — `net:tailscale` will sit there waiting. Use a
|
||||
session file with a cube that has no secrets, or answer the prompt.
|
||||
|
||||
## Phase 6 — documentation — **done**
|
||||
|
||||
- `CLAUDE.md`: the repo table gains two rows (`packages/nopy-cube`,
|
||||
`packages/cubes-core`) and loses the `cubes/` one; "The two packages do not
|
||||
@@ -503,6 +597,21 @@ has to be in place before either package is published.
|
||||
plus the ordering constraint — `nopy-cube` releases before anything that
|
||||
depends on it.
|
||||
|
||||
Beyond the list: `CLAUDE.md` also needed the `typecheck` command corrected
|
||||
(`tsc --build`, not `--noEmit` — see Phase 4), a note on the vitest source alias
|
||||
and the coverage exclusion, and three entries under *Known drift*. `README.PUBLISH.md`
|
||||
absorbed the whole publish-lane rework, not just the tag prefixes: `pnpm publish`
|
||||
over `npm publish` and why, the two-pass version stamping, `verify-pack.mjs`,
|
||||
`publish-order.mjs`, `linked-deps.mjs`, and a local rehearsal recipe that uses
|
||||
**npm** to install the tarballs precisely because npm is the one that rejects a
|
||||
leaked `workspace:` range.
|
||||
|
||||
One workflow change came out of writing this up: `ci.yml` now runs
|
||||
`verify-pack.mjs` too. It was only in the two publish workflows, which means a
|
||||
leaked range would have failed the release rather than the pull request that
|
||||
introduced it — the wrong end of the process for a mistake that is free to catch
|
||||
early.
|
||||
|
||||
## Testing
|
||||
|
||||
The coverage gate (85 % branches/functions, 80 % lines/statements, per package)
|
||||
|
||||
@@ -54,3 +54,29 @@ This document tracks the major refactoring of the `nopy` package.
|
||||
- `VariableAssignment` offers every schema key, not only the ones carrying a default, and shows the value the run would actually use as the initial.
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
|
||||
### 6. Make variable assignment a first-class concept
|
||||
- **Status**: ✅ Completed
|
||||
- **Goal**: Give a variable an identity and a provenance, instead of inferring both from which bag it happened to sit in.
|
||||
- **Rationale**: Item 5 left precedence encoded as the field order of an object literal inside `Variables.get()` — `defaults`, then `global`, then `prompts`, then `params`. Nothing named the ranking, nothing could be asked where a value came from, and a replay had to be smuggled into the `prompts` bag because there was no origin that meant "recorded". Every question that followed — what should a session record, which values are safe to print — needed provenance to answer.
|
||||
- **Context**:
|
||||
- `Assignment { value, origin }` and an `Origin` ranked `default(0) < env(1) < session(2) < prompt(3) < param(4)`. Precedence is now data, not the order lines appear in.
|
||||
- `Variable` is a class over an assignment list. `assignments` is the true history, newest first and never reordered; `ordered` is a *stable* sort of it by origin rank, and `value`/`origin` read the head of that. Stability is what makes the two views coexist: same-origin ties keep the newest in front while the value it displaced stays visible.
|
||||
- The `global` bag is gone. Config `env` is seeded per cube as a real assignment at origin `env`, so `variables.get('global')` — a cube id that was never a cube — is no longer a thing.
|
||||
- Replay assigns at origin `session`, which outranks `env` and `default` on its own. The `prompts`-bag workaround is deleted.
|
||||
- A session records `Variables.persistable()` — every effective value, not just prompted ones. A `-D` run used to record nothing and replay by re-deriving from whatever the defaults said at replay time.
|
||||
- **Trade-off accepted**: recorded values now outrank the current `.nopyrc.json` `env` and the current schema defaults, so editing either no longer leaks into an existing session's replay. That is the point of a snapshot, but it does mean picking up a new default requires re-recording.
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
### 7. Manifest-declared secrets
|
||||
- **Status**: ✅ Completed
|
||||
- **Goal**: Let a manifest say which schema keys hold sensitive values, and act on it.
|
||||
- **Rationale**: Item 6 made sessions record everything, which forced the question of what must *not* be recorded. The codebase already had an answer of sorts — `outputExecutionPlan` masked any variable whose name contained "password" — that missed `TOKEN`, `PSK` and `AUTH_KEY`, and was defeated anyway by the unmasked command printed one line above it.
|
||||
- **Context**:
|
||||
- `Manifest.secrets?: string[]`, validated at load: an entry that is not a key of `schema` is a manifest error and aborts the run, so a typo cannot silently leave a value unprotected.
|
||||
- Deliberately a plain array, not zod metadata. `.meta()` and `.describe()` store into `z.globalRegistry`, which is per-copy — a manifest built by a different zod copy would look up empty. Fail-open is fine for a missing prompt label and unacceptable for a secret marker.
|
||||
- `maskCommand()` replaces declared `--data` values and the SSH `--password` in the command string itself, and is wired into `--print-only`, the dry-run plan and the debug log. The `nopy` logger runs at `lowestLevel: 'debug'`, so that last one was printing credentials on every run.
|
||||
- Secrets are excluded from `persistable()`, so a replay has a gap where one used to be. `fillSessionGaps` prompts for `requiredKeys() ∪ secrets`; under `-D` it fails naming them, consistent with item 5's fail-fast.
|
||||
- **Scope limit**: `secrets` keeps a value out of what nopy writes. The value is still on pyinfra's command line (visible in `ps`), still echoed by the variable form, and a `.default()` is still plain text in the manifest. Documented rather than fixed — the first is inherent to pyinfra's interface.
|
||||
- **Bug fixed along the way**: `cubes/user/add` generated a random password as its schema `.default()`. Because the key had a default it was never in `requiredKeys()`, and because a generated default is re-evaluated on every read, an unattended run created an account with a credential nobody had seen and a replay created a different one again. It is now the literal `changeme`.
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
@@ -294,8 +294,9 @@ export default {
|
||||
2. **Document your cubes** - Add comments explaining what each cube does
|
||||
3. **Use environment variables** - Make sessions reusable across environments
|
||||
4. **Extract common config** - Share configuration across multiple sessions
|
||||
5. **Version control** - Both formats work well with git
|
||||
5. **Version control** - Both formats work well with git, but a recorded session holds every value its run used; read one before committing it
|
||||
6. **Validate at runtime** - The loader validates the structure regardless of format
|
||||
7. **Leave secrets out** - Declare them in the manifest instead, and let the replay ask
|
||||
|
||||
## Session Schema
|
||||
|
||||
@@ -321,3 +322,16 @@ interface AuthSession {
|
||||
username?: string;
|
||||
}
|
||||
```
|
||||
|
||||
A session nopy *writes* holds, per cube, every value that cube ran with — what
|
||||
was typed, what came from `.nopyrc.json`, what a dependency supplied, and what
|
||||
fell through to the schema's `.default()`. Two things are deliberately absent and
|
||||
are asked for again on replay: the SSH password, and any key the cube's manifest
|
||||
listed under `secrets`.
|
||||
|
||||
A session you write by hand is under no such obligation — `variables` may hold as
|
||||
few keys as you like, and anything missing resolves the usual way. Note that a
|
||||
key declared a secret is prompted for whether or not the session carries a value:
|
||||
writing one in only pre-fills the prompt, it does not skip it. The variable form
|
||||
shows what it is editing, so a secret you put in a session file appears on screen
|
||||
as well as on disk.
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc",
|
||||
"build": "tsc && cp src/cubes/*.mjs dist/cubes/",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"nopy": "tsx src/nopy.cli.ts",
|
||||
@@ -54,6 +54,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitsquare/nopy-cube": "workspace:*",
|
||||
"@logtape/logtape": "^2.2.4",
|
||||
"commander": "^15.0.0",
|
||||
"enquirer": "^2.4.1",
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
|
||||
import type { Cube, CubeVariables, HookContext } from '@bitsquare/nopy-cube';
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import type { Variables } from '../nopy.common.js';
|
||||
import type { NopyConfig } from '../nopy.config.js';
|
||||
import type { DeployCall } from '../nopy.executor.js';
|
||||
import { VariableAssignment } from '../nopy.prompts.js';
|
||||
import type { CubeSession, NopySession } from '../nopy.session.js';
|
||||
import type { Cube, CubeVariables, HookContext } from './types.js';
|
||||
|
||||
const log = getLogger(['nopy', 'resolution']);
|
||||
|
||||
@@ -38,6 +38,12 @@ export class BuildContext {
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
/** Required schema keys that nothing has supplied a value for. */
|
||||
private missingRequired(cube: Cube): string[] {
|
||||
const resolved = this.variables.get(cube.id);
|
||||
return cube.requiredKeys().filter((key) => resolved[key] === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails a non-interactive run that cannot fill a required variable.
|
||||
*
|
||||
@@ -45,8 +51,7 @@ export class BuildContext {
|
||||
* `--data`, and the deploy script would read `None` off `host.data`.
|
||||
*/
|
||||
private assertVariablesComplete(cube: Cube): void {
|
||||
const resolved = this.variables.get(cube.id);
|
||||
const missing = cube.requiredKeys().filter((key) => resolved[key] === undefined);
|
||||
const missing = this.missingRequired(cube);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const [one, them] =
|
||||
@@ -58,6 +63,43 @@ export class BuildContext {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the variables a replay cannot supply on its own.
|
||||
*
|
||||
* Two kinds. Required keys can be absent because the session predates them or
|
||||
* was recorded by a `--use-defaults` run. Secrets are absent by design: they
|
||||
* are never written to a session, so replaying without asking would deploy a
|
||||
* cube with the key missing — or, for a secret carrying a default, with a
|
||||
* value silently different from the run being replayed.
|
||||
*
|
||||
* Secrets are asked for even when a default did fill them in, which is why
|
||||
* this cannot key off "has no value": the whole point is that the recorded
|
||||
* answer is gone and only the user knows what it was.
|
||||
*/
|
||||
private async fillSessionGaps(cube: Cube): Promise<void> {
|
||||
const gaps = [...new Set([...this.missingRequired(cube), ...cube.secrets])];
|
||||
if (gaps.length === 0) return;
|
||||
|
||||
if (this.options.useDefaults) {
|
||||
throw new Error(
|
||||
`Cube "${cube.id}" cannot be replayed with --use-defaults: ${gaps.join(', ')} ` +
|
||||
'would have to be entered. Secrets are never recorded in a session. ' +
|
||||
'Replay without --use-defaults, or set the values under "env" in .nopyrc.json.'
|
||||
);
|
||||
}
|
||||
|
||||
log.debug('Filling session gaps', { cubeId: cube.id, gaps });
|
||||
await VariableAssignment(cube, this.variables, { keys: gaps });
|
||||
|
||||
// A cancelled form leaves the run short of a value it cannot invent.
|
||||
const stillMissing = this.missingRequired(cube);
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(
|
||||
`Cube "${cube.id}" is missing ${stillMissing.join(', ')} and cannot be deployed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
@@ -73,20 +115,22 @@ export class BuildContext {
|
||||
|
||||
log.debug('Resolving cube', { cubeId, host });
|
||||
|
||||
// 1. Assign overrides and defaults
|
||||
// 1. Declare secrets, then assign overrides and defaults. Declaring first
|
||||
// means even the config `env` seeded on the cube's first assignment is
|
||||
// already marked, so nothing reaches a session or a log unredacted.
|
||||
this.variables.declareSecrets(cubeId, cube.secrets);
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
this.variables.assign(cubeId, 'params', overrides);
|
||||
this.variables.assign(cubeId, 'param', overrides);
|
||||
}
|
||||
this.variables.assign(cubeId, 'defaults', cube.getDefaults());
|
||||
this.variables.assign(cubeId, 'default', cube.getDefaults());
|
||||
|
||||
// 2. Variable collection
|
||||
if (this.options.isSessionReplay) {
|
||||
// Recorded answers go back into the scope they came from, so a replay
|
||||
// reproduces them even when `env` sets the same key to something else.
|
||||
const sessionCube = this.session.cubes.find((c) => c.key === cubeId);
|
||||
if (sessionCube) {
|
||||
this.variables.assign(cubeId, 'prompts', sessionCube.variables);
|
||||
this.variables.assign(cubeId, 'session', sessionCube.variables);
|
||||
}
|
||||
await this.fillSessionGaps(cube);
|
||||
} else if (this.options.useDefaults) {
|
||||
log.debug('Skipping prompts, using defaults', { cubeId });
|
||||
this.assertVariablesComplete(cube);
|
||||
@@ -155,13 +199,18 @@ export class BuildContext {
|
||||
cwd: cube.dir,
|
||||
command,
|
||||
env: cubeVars,
|
||||
secrets: cube.secrets,
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
if (!this.cubeSessions.some((s) => s.key === cubeId)) {
|
||||
// Every value the run settled on, not just the prompted ones — otherwise a
|
||||
// `--use-defaults` run records nothing and replaying it re-derives from
|
||||
// whatever the defaults and `env` happen to say now. Secrets are the one
|
||||
// exclusion; a replay asks for those again.
|
||||
this.cubeSessions.push({
|
||||
key: cubeId,
|
||||
variables: this.variables.get(cubeId, 'prompts'),
|
||||
variables: this.variables.persistable(cubeId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,34 +6,37 @@
|
||||
* @module cubes
|
||||
*/
|
||||
|
||||
// Dependencies
|
||||
export { BuildContext } from './dependencies.js';
|
||||
// Factory functions
|
||||
export {
|
||||
createManifest,
|
||||
manifest,
|
||||
} from './factories.js';
|
||||
// Loader
|
||||
export {
|
||||
findCubeDirectories,
|
||||
getCube,
|
||||
loadCubes,
|
||||
} from './loader.js';
|
||||
// The authoring surface lives in its own package so that a cube bundle can
|
||||
// depend on it without pulling the CLI in. Re-exported here so that
|
||||
// `import { cubes } from '@bitsquare/nopy'` in a manifest keeps working.
|
||||
export type {
|
||||
AnyObjectSchema,
|
||||
CubeSource,
|
||||
CubeVariables,
|
||||
DependencySpec,
|
||||
Hook,
|
||||
HookContext,
|
||||
LoadResult,
|
||||
} from './types.js';
|
||||
// Types
|
||||
} from '@bitsquare/nopy-cube';
|
||||
export {
|
||||
Cube,
|
||||
createManifest,
|
||||
Manifest,
|
||||
manifest,
|
||||
uniqid,
|
||||
zodInner,
|
||||
zodKind,
|
||||
} from './types.js';
|
||||
|
||||
// Utilities
|
||||
export { uniqid } from './utils.js';
|
||||
} from '@bitsquare/nopy-cube';
|
||||
// Dependencies
|
||||
export { BuildContext } from './dependencies.js';
|
||||
// Loader
|
||||
export type { CubeRoot } from './loader.js';
|
||||
export {
|
||||
findCubeDirectories,
|
||||
findCubeRoots,
|
||||
getCube,
|
||||
loadCubes,
|
||||
} from './loader.js';
|
||||
// Packages
|
||||
export type { CubePackage } from './packages.js';
|
||||
export { resolveCubePackages } from './packages.js';
|
||||
|
||||
@@ -3,21 +3,55 @@
|
||||
* @module cubes/loader
|
||||
*/
|
||||
|
||||
import module from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { Cube, type CubeSource, type LoadResult, type Manifest } from '@bitsquare/nopy-cube';
|
||||
import { z } from 'zod';
|
||||
import { fs } from 'zx';
|
||||
import { loadConfig } from '../nopy.config.js';
|
||||
import { Cube, type LoadResult, type Manifest } from './types.js';
|
||||
import { resolveCubePackages } from './packages.js';
|
||||
|
||||
let hookRegistered = false;
|
||||
|
||||
/**
|
||||
* Traverses upwards from the current working directory to the root
|
||||
* and collects all directories that contain a `.npcubes` marker file.
|
||||
* Installs the fallback resolver that lets a manifest in a bare directory
|
||||
* import `@bitsquare/nopy-cube` or `zod` — see `resolve-hook.mjs`.
|
||||
*
|
||||
* Also includes directories specified in the `.nopyrc.json` configuration.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
* `module.register()` is process-global and cannot be undone, so this runs once
|
||||
* and only when cubes are about to be imported. Registration failing is not
|
||||
* worth aborting a run over: without the hook, a cube that needed it fails on
|
||||
* its own import with a message that names the file.
|
||||
*/
|
||||
export function findCubeDirectories(): string[] {
|
||||
function registerResolveHook(): void {
|
||||
if (hookRegistered) return;
|
||||
hookRegistered = true;
|
||||
|
||||
try {
|
||||
// `from` is a URL inside this package, so the hook thread resolves the
|
||||
// fallbacks out of the running CLI's own dependencies.
|
||||
module.register('./resolve-hook.mjs', import.meta.url, { data: { from: import.meta.url } });
|
||||
} catch {
|
||||
// Nothing to do: the hook is a convenience, never load-bearing.
|
||||
}
|
||||
}
|
||||
|
||||
/** A directory to scan, and what put it in the list. */
|
||||
export interface CubeRoot {
|
||||
dir: string;
|
||||
source: CubeSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every root to scan for cubes:
|
||||
*
|
||||
* - `cubeDirs` from the merged configuration,
|
||||
* - every ancestor of the working directory holding a `.npcubes` marker file,
|
||||
* - the cube directories of every package named in `cubePackages`.
|
||||
*
|
||||
* Only the last of those can fail — a missing directory is ignored, a missing
|
||||
* package is not (see `resolveCubePackages`).
|
||||
*/
|
||||
export function findCubeRoots(): { roots: CubeRoot[]; errors: string[] } {
|
||||
let currentDir = process.cwd();
|
||||
const config = loadConfig();
|
||||
const dirSet = new Set<string>(config.cubeDirs.map((dir) => path.resolve(process.cwd(), dir)));
|
||||
@@ -38,7 +72,29 @@ export function findCubeDirectories(): string[] {
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
return [...dirSet];
|
||||
const roots: CubeRoot[] = [...dirSet].map((dir) => ({ dir, source: { type: 'dir', dir } }));
|
||||
|
||||
const { packages, errors } = resolveCubePackages(config.cubePackages);
|
||||
for (const pkg of packages) {
|
||||
for (const dir of pkg.dirs) {
|
||||
roots.push({ dir, source: { type: 'package', packageName: pkg.name, dir } });
|
||||
}
|
||||
}
|
||||
|
||||
return { roots, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* The directories {@link findCubeRoots} would scan.
|
||||
*
|
||||
* Kept for callers that only want the paths; anything that needs to attribute
|
||||
* a cube to where it came from should use `findCubeRoots` instead, which also
|
||||
* reports the errors this one drops.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
*/
|
||||
export function findCubeDirectories(): string[] {
|
||||
return findCubeRoots().roots.map((root) => root.dir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,10 +112,12 @@ interface CubeCandidate {
|
||||
manifest: Manifest;
|
||||
dir: string;
|
||||
deployScript: string;
|
||||
source: CubeSource;
|
||||
}
|
||||
|
||||
/** What one root directory contributed. */
|
||||
interface ScanResult {
|
||||
root: CubeRoot;
|
||||
candidates: CubeCandidate[];
|
||||
errors: string[];
|
||||
}
|
||||
@@ -99,11 +157,23 @@ async function scanDirectory(currentDir: string, result: ScanResult): Promise<vo
|
||||
manifest.id = cubeId;
|
||||
manifest.schema = manifest.schema ?? z.object({});
|
||||
|
||||
// A `secrets` entry naming a key that is not in the schema protects
|
||||
// nothing, and a typo in one is invisible at runtime — the value would
|
||||
// just be persisted. Cheaper to refuse the cube than to ship the leak.
|
||||
const unknown = (manifest.secrets ?? []).filter((key) => !(key in manifest.schema.shape));
|
||||
if (unknown.length > 0) {
|
||||
result.errors.push(
|
||||
`Invalid manifest in ${manifestPath}: 'secrets' names ${unknown.join(', ')}, ` +
|
||||
`which ${unknown.length === 1 ? 'is' : 'are'} not in the schema`
|
||||
);
|
||||
}
|
||||
|
||||
result.candidates.push({
|
||||
id: cubeId,
|
||||
manifest,
|
||||
dir: currentDir,
|
||||
deployScript: deployFile.name,
|
||||
source: result.root.source,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -118,9 +188,23 @@ async function scanDirectory(currentDir: string, result: ScanResult): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
/** The message a duplicate id produces. Aborts the run — see `nopy.main.ts`. */
|
||||
/**
|
||||
* The message a duplicate id produces. Aborts the run — see `nopy.main.ts`.
|
||||
*
|
||||
* There is deliberately no precedence rule to fall back on: two cubes claiming
|
||||
* one id are mutually exclusive, and the fix belongs upstream. So the message
|
||||
* has to carry everything needed to go and make it, which means naming every
|
||||
* claimant and how each got into the run.
|
||||
*/
|
||||
function duplicateError(id: string, group: CubeCandidate[]): string {
|
||||
const where = group.map((c) => ` ${c.dir}`).join('\n');
|
||||
const label = (candidate: CubeCandidate) =>
|
||||
candidate.source.type === 'package' ? `package ${candidate.source.packageName}` : 'directory';
|
||||
const width = Math.max(...group.map((candidate) => label(candidate).length));
|
||||
|
||||
const where = group
|
||||
.map((candidate) => ` ${label(candidate).padEnd(width)} ${candidate.dir}`)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
`Duplicate cube id '${id}' from ${group.length} sources:\n${where}\n` +
|
||||
`Rename one of them, or remove a source from .nopyrc.json.`
|
||||
@@ -137,20 +221,21 @@ function duplicateError(id: string, group: CubeCandidate[]): string {
|
||||
* run, which is what makes the hard error testable.
|
||||
*/
|
||||
export async function loadCubes(): Promise<LoadResult> {
|
||||
const cubesFolders = findCubeDirectories();
|
||||
const { roots, errors: rootErrors } = findCubeRoots();
|
||||
registerResolveHook();
|
||||
|
||||
const scans = await Promise.all(
|
||||
cubesFolders.map(async (folder) => {
|
||||
const result: ScanResult = { candidates: [], errors: [] };
|
||||
if (fs.existsSync(folder)) {
|
||||
await scanDirectory(folder, result);
|
||||
roots.map(async (root) => {
|
||||
const result: ScanResult = { root, candidates: [], errors: [] };
|
||||
if (fs.existsSync(root.dir)) {
|
||||
await scanDirectory(root.dir, result);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
// Promise.all preserves input order regardless of completion order.
|
||||
const errors = scans.flatMap((scan) => scan.errors);
|
||||
const errors = [...rootErrors, ...scans.flatMap((scan) => scan.errors)];
|
||||
|
||||
// One directory reachable from two roots (a `cubeDirs` entry nested under a
|
||||
// `.npcubes` marker, say) is one cube seen twice, not a collision.
|
||||
@@ -172,7 +257,7 @@ export async function loadCubes(): Promise<LoadResult> {
|
||||
// The map is still populated for the callers that only report; a duplicate
|
||||
// is fatal, so which candidate landed here never reaches a deploy.
|
||||
const [first] = group;
|
||||
cubes[id] = new Cube(first.manifest, first.dir, first.deployScript);
|
||||
cubes[id] = new Cube(first.manifest, first.dir, first.deployScript, first.source);
|
||||
}
|
||||
|
||||
return { cubes, errors };
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Resolving cube packages named in `cubePackages` to directories on disk.
|
||||
* @module cubes/packages
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import type { CubePackageRef } from '../nopy.config.js';
|
||||
|
||||
/** An installed cube package, located and validated. */
|
||||
export interface CubePackage {
|
||||
/** The name it was requested under. */
|
||||
name: string;
|
||||
/** Absolute path to the package root. */
|
||||
root: string;
|
||||
/** Absolute paths to its cube directories, from `nopy.cubes`. */
|
||||
dirs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a package root without going through its `exports` map.
|
||||
*
|
||||
* `exports` is deliberately bypassed: a cube bundle ships directories, not an
|
||||
* entry point, and requiring it to declare one would make the contract heavier
|
||||
* for no gain. Reading `package.json` off disk also sidesteps pnpm's layout —
|
||||
* `existsSync` follows the symlink pnpm plants at `node_modules/<name>`, which
|
||||
* a directory scan would skip (`readdir` reports it as a symlink, not a
|
||||
* directory).
|
||||
*/
|
||||
function findPackageRoot(ref: CubePackageRef): string | undefined {
|
||||
// createRequire needs a file path, not a directory; the file need not exist.
|
||||
const req = createRequire(path.join(ref.from, 'noop.js'));
|
||||
for (const dir of req.resolve.paths(ref.spec) ?? []) {
|
||||
if (fs.existsSync(path.join(dir, ref.spec, 'package.json'))) {
|
||||
return path.join(dir, ref.spec);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every named package to its cube directories.
|
||||
*
|
||||
* Anything wrong is an error rather than a silent skip: naming a package in
|
||||
* `cubePackages` is a statement that cubes are expected from it, and errors
|
||||
* abort the run (see `nopy.main.ts`).
|
||||
*/
|
||||
export function resolveCubePackages(refs: CubePackageRef[]): {
|
||||
packages: CubePackage[];
|
||||
errors: string[];
|
||||
} {
|
||||
const packages: CubePackage[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// `mergeValue` only de-duplicates arrays of primitives, and these are
|
||||
// objects, so the same package named by a parent and a child config arrives
|
||||
// twice. Last wins: configs merge root-first, so the last occurrence came
|
||||
// from the most specific config and carries the right resolution origin.
|
||||
const unique = new Map<string, CubePackageRef>();
|
||||
for (const ref of refs) unique.set(ref.spec, ref);
|
||||
|
||||
for (const ref of unique.values()) {
|
||||
const root = findPackageRoot(ref);
|
||||
if (!root) {
|
||||
errors.push(`Cube package '${ref.spec}' is not installed (looked up from ${ref.from}).`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest: { nopy?: { cubes?: unknown } };
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));
|
||||
} catch (err) {
|
||||
errors.push(`Cube package '${ref.spec}': cannot read ${root}/package.json: ${err}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const declared = manifest.nopy?.cubes;
|
||||
if (
|
||||
!Array.isArray(declared) ||
|
||||
declared.length === 0 ||
|
||||
!declared.every((entry) => typeof entry === 'string')
|
||||
) {
|
||||
errors.push(
|
||||
`Cube package '${ref.spec}' declares no cubes. ` +
|
||||
`Expected "nopy": { "cubes": ["./cubes"] } in ${root}/package.json.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
for (const entry of declared as string[]) {
|
||||
const dir = path.resolve(root, entry);
|
||||
|
||||
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
||||
errors.push(`Cube package '${ref.spec}': '${entry}' points outside the package.`);
|
||||
} else if (!fs.existsSync(dir)) {
|
||||
errors.push(`Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`);
|
||||
} else {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if (dirs.length > 0) packages.push({ name: ref.spec, root, dirs });
|
||||
}
|
||||
|
||||
return { packages, errors };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* A module resolve hook that lets a hand-written cube import the packages nopy
|
||||
* itself already has.
|
||||
*
|
||||
* A manifest is loaded with `import(manifestPath)`, so its imports resolve from
|
||||
* its own directory. A cube sitting in an arbitrary `cubeDirs` entry — no
|
||||
* package.json above it, no node_modules beside it — therefore cannot import
|
||||
* `@bitsquare/nopy-cube` or `zod` at all, and the run dies on
|
||||
* ERR_MODULE_NOT_FOUND before a single deploy is built.
|
||||
*
|
||||
* A published cube bundle never reaches this: it declares its own dependencies
|
||||
* and Node resolves them normally. This is for the local tree.
|
||||
*
|
||||
* Plain `.mjs` rather than TypeScript because the hook runs on its own thread,
|
||||
* loaded by Node directly from `dist` — there is no compile step in that path.
|
||||
*
|
||||
* @module cubes/resolve-hook
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/**
|
||||
* The specifiers worth rescuing: what a manifest legitimately needs and cannot
|
||||
* be expected to install for itself. Anything else stays a hard failure — a
|
||||
* cube that wants a library should depend on it.
|
||||
*/
|
||||
const FALLBACK_ROOTS = ['@bitsquare/nopy-cube', '@bitsquare/nopy', 'zod'];
|
||||
|
||||
/** @type {NodeRequire | undefined} */
|
||||
let fallbackRequire;
|
||||
|
||||
/**
|
||||
* @param {{ from: string }} data - a URL inside the running CLI's own package,
|
||||
* which is where the fallback resolution starts from.
|
||||
*/
|
||||
export function initialize(data) {
|
||||
fallbackRequire = createRequire(data.from);
|
||||
}
|
||||
|
||||
/** True for `zod` and for subpaths like `zod/v4` or `@bitsquare/nopy/package.json`. */
|
||||
function isCovered(specifier) {
|
||||
return FALLBACK_ROOTS.some((root) => specifier === root || specifier.startsWith(`${root}/`));
|
||||
}
|
||||
|
||||
export async function resolve(specifier, context, next) {
|
||||
try {
|
||||
// Normal resolution first, always. A consumer that has its own copy
|
||||
// installed keeps using it, so the hook can never introduce version skew —
|
||||
// it only fills in for a lookup that was going to fail.
|
||||
return await next(specifier, context);
|
||||
} catch (error) {
|
||||
if (!fallbackRequire || !isCovered(specifier)) throw error;
|
||||
try {
|
||||
return { url: pathToFileURL(fallbackRequire.resolve(specifier)).href, shortCircuit: true };
|
||||
} catch {
|
||||
// The CLI cannot see it either. Report the original failure, which names
|
||||
// the importer rather than the CLI.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
// Cubes module
|
||||
export * from './cubes/index.js';
|
||||
export type { Assignment, Origin, TVariables, Value } from './nopy.common.js';
|
||||
// Variables
|
||||
export { MASK, Variable, Variables } from './nopy.common.js';
|
||||
export type {
|
||||
ExecutionConfig,
|
||||
HistoryConfig,
|
||||
@@ -28,6 +31,8 @@ export type {
|
||||
// Executor
|
||||
export {
|
||||
executeDeployCalls,
|
||||
maskCommand,
|
||||
maskVariables,
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
@@ -60,14 +65,7 @@ export {
|
||||
} from './nopy.prompts.js';
|
||||
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
|
||||
// Session management
|
||||
export {
|
||||
createSession,
|
||||
filterInternalVariables,
|
||||
listSessions,
|
||||
loadSession,
|
||||
saveSession,
|
||||
separateEnvAndCubeVariables,
|
||||
} from './nopy.session.js';
|
||||
export { createSession, listSessions, loadSession, saveSession } from './nopy.session.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
// Workflow
|
||||
export {
|
||||
|
||||
@@ -1,49 +1,204 @@
|
||||
/**
|
||||
* Environment variable configuration
|
||||
* Variable assignment and provenance
|
||||
* @module nopy.common
|
||||
*/
|
||||
export type TVariables = Record<string, string | number | boolean>;
|
||||
|
||||
export namespace Variables {
|
||||
export type ArtefactId = string;
|
||||
export type Scope = 'defaults' | 'prompts' | 'params';
|
||||
/** What a cube variable can hold — the value types `--data KEY=VALUE` can carry. */
|
||||
export type Value = string | number | boolean;
|
||||
|
||||
/** A flat bag of variable values, keyed by name. */
|
||||
export type TVariables = Record<string, Value>;
|
||||
|
||||
/**
|
||||
* Where a value came from, in ascending precedence.
|
||||
*
|
||||
* The order is the point. It used to be implied by the field order of an object
|
||||
* literal inside `Variables.get()` — load-bearing, invisible, and one careless
|
||||
* reformat away from silently changing which value wins. Here it is stated once,
|
||||
* in {@link RANK}, and everything else derives from it.
|
||||
*
|
||||
* - `default` — a `.default()` on the cube's schema
|
||||
* - `env` — the `env` block of `.nopyrc.json`
|
||||
* - `session` — read back from a recorded session on replay
|
||||
* - `prompt` — what the user typed
|
||||
* - `param` — handed over by a dependency spec or a hook's `exec()`
|
||||
*/
|
||||
export type Origin = 'default' | 'env' | 'session' | 'prompt' | 'param';
|
||||
|
||||
const RANK: Record<Origin, number> = {
|
||||
default: 0,
|
||||
env: 1,
|
||||
session: 2,
|
||||
prompt: 3,
|
||||
param: 4,
|
||||
};
|
||||
|
||||
/** One value handed to a variable, and where it came from. */
|
||||
export interface Assignment {
|
||||
value: Value;
|
||||
origin: Origin;
|
||||
}
|
||||
|
||||
export class Variables {
|
||||
/** @summary env as configured in cube or session script */
|
||||
defaults: Record<Variables.ArtefactId, TVariables> = {};
|
||||
/** @summary env as configured via prompts */
|
||||
prompts: Record<Variables.ArtefactId, TVariables> = {};
|
||||
/** @summary env as handed via params (on hook calls) */
|
||||
params: Record<Variables.ArtefactId, TVariables> = {};
|
||||
/** What a secret shows as wherever a value would otherwise be printed. */
|
||||
export const MASK = '********';
|
||||
|
||||
constructor(readonly global: TVariables = {}) {}
|
||||
/**
|
||||
* One variable of one cube, and every value it has ever been given.
|
||||
*
|
||||
* Two orderings are kept, deliberately: {@link assignments} is the raw trace in
|
||||
* the order things happened, and {@link ordered} re-ranks it by origin. The
|
||||
* first answers "how did we get here", the second answers "what wins".
|
||||
*/
|
||||
export class Variable {
|
||||
/** Every assignment received, newest first. Never reordered. */
|
||||
readonly assignments: Assignment[] = [];
|
||||
|
||||
assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values: TVariables = {}) {
|
||||
if (!this[scope][artefactId]) {
|
||||
this[scope][artefactId] = values;
|
||||
} else {
|
||||
Object.assign(this[scope][artefactId], values);
|
||||
}
|
||||
/**
|
||||
* Declared a secret by the cube's manifest: kept out of saved sessions and
|
||||
* masked wherever the value would otherwise be printed.
|
||||
*/
|
||||
redacted = false;
|
||||
|
||||
constructor(
|
||||
readonly cube: string,
|
||||
readonly name: string,
|
||||
first: Assignment
|
||||
) {
|
||||
this.assign(first);
|
||||
}
|
||||
|
||||
assign(assignment: Assignment): void {
|
||||
this.assignments.unshift(assignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the scopes for one cube, lowest precedence first:
|
||||
* schema defaults → global `env` → prompts (or replayed session values) →
|
||||
* params handed over by a dependency or a hook.
|
||||
* The trace re-ranked by origin, winner first.
|
||||
*
|
||||
* Defaults sit at the bottom so `env` in `.nopyrc.json` can steer a run that
|
||||
* never prompts (`--use-defaults`); a key that a dependency supplies is never
|
||||
* prompted for, so prompts and params do not compete in practice.
|
||||
* Stability is load-bearing here. The trace is newest-first and
|
||||
* `Array.prototype.sort` is stable per spec, so two assignments sharing an
|
||||
* origin keep their relative order and the newer one stays in front: the
|
||||
* second dependency to pass a param wins, and the one it displaced is still
|
||||
* visible underneath instead of being overwritten out of existence.
|
||||
*/
|
||||
get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables {
|
||||
if (scope) {
|
||||
return this[scope][artefactId] || {};
|
||||
}
|
||||
get ordered(): Assignment[] {
|
||||
return [...this.assignments].sort((a, b) => RANK[b.origin] - RANK[a.origin]);
|
||||
}
|
||||
|
||||
/** The assignment that wins. Never undefined — a Variable is born with one. */
|
||||
get effective(): Assignment {
|
||||
return this.ordered[0];
|
||||
}
|
||||
|
||||
get value(): Value {
|
||||
return this.effective.value;
|
||||
}
|
||||
|
||||
get origin(): Origin {
|
||||
return this.effective.origin;
|
||||
}
|
||||
|
||||
/** Safe to log: a redacted variable never yields its value. */
|
||||
toJSON(): { cube: string; name: string; value: Value; origin: Origin } {
|
||||
return {
|
||||
...this.defaults[artefactId],
|
||||
...this.global,
|
||||
...this.prompts[artefactId],
|
||||
...this.params[artefactId],
|
||||
cube: this.cube,
|
||||
name: this.name,
|
||||
value: this.redacted ? MASK : this.value,
|
||||
origin: this.origin,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every variable of every cube in one run, with its provenance.
|
||||
*/
|
||||
export class Variables {
|
||||
private readonly store: Record<string, Record<string, Variable>> = {};
|
||||
private readonly secrets: Record<string, Set<string>> = {};
|
||||
|
||||
constructor(readonly env: TVariables = {}) {}
|
||||
|
||||
/**
|
||||
* Marks keys of one cube as holding secrets.
|
||||
*
|
||||
* Retroactive as well as prospective, so it does not matter whether the
|
||||
* caller declares before or after the values arrive.
|
||||
*/
|
||||
declareSecrets(cube: string, keys: readonly string[]): void {
|
||||
this.secrets[cube] ??= new Set<string>();
|
||||
const declared = this.secrets[cube];
|
||||
for (const key of keys) declared.add(key);
|
||||
for (const variable of this.all(cube)) {
|
||||
if (declared.has(variable.name)) variable.redacted = true;
|
||||
}
|
||||
}
|
||||
|
||||
isSecret(cube: string, name: string): boolean {
|
||||
return this.secrets[cube]?.has(name) ?? false;
|
||||
}
|
||||
|
||||
/** Records values for one cube, all at the same origin. */
|
||||
assign(cube: string, origin: Origin, values: TVariables = {}): void {
|
||||
const bucket = this.bucket(cube);
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const existing = bucket[name];
|
||||
if (existing) existing.assign({ value, origin });
|
||||
else bucket[name] = this.create(cube, name, { value, origin });
|
||||
}
|
||||
}
|
||||
|
||||
/** Every variable known for one cube. */
|
||||
all(cube: string): Variable[] {
|
||||
return Object.values(this.store[cube] ?? {});
|
||||
}
|
||||
|
||||
/** One variable, or `undefined` if nothing has ever assigned to it. */
|
||||
of(cube: string, name: string): Variable | undefined {
|
||||
return this.store[cube]?.[name];
|
||||
}
|
||||
|
||||
/** The effective values for one cube — what goes on the pyinfra command line. */
|
||||
get(cube: string): TVariables {
|
||||
const values: TVariables = {};
|
||||
for (const variable of this.all(cube)) values[variable.name] = variable.value;
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective values minus anything declared secret — what a session
|
||||
* records. A secret is left out entirely rather than masked, so a replay sees
|
||||
* it as absent and asks for it again.
|
||||
*/
|
||||
persistable(cube: string): TVariables {
|
||||
const values: TVariables = {};
|
||||
for (const variable of this.all(cube)) {
|
||||
if (!variable.redacted) values[variable.name] = variable.value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private create(cube: string, name: string, first: Assignment): Variable {
|
||||
const variable = new Variable(cube, name, first);
|
||||
variable.redacted = this.isSecret(cube, name);
|
||||
return variable;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cube's bucket, seeded on creation with the config `env`.
|
||||
*
|
||||
* `env` applies to every cube, so it becomes a real assignment on each of them
|
||||
* rather than a parallel bag merged in at read time. That is what lets it
|
||||
* carry an origin, show up in the trace, and lose to a prompt by the same rule
|
||||
* as everything else.
|
||||
*/
|
||||
private bucket(cube: string): Record<string, Variable> {
|
||||
const existing = this.store[cube];
|
||||
if (existing) return existing;
|
||||
|
||||
const bucket: Record<string, Variable> = {};
|
||||
this.store[cube] = bucket;
|
||||
for (const [name, value] of Object.entries(this.env)) {
|
||||
bucket[name] = this.create(cube, name, { value, origin: 'env' });
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* @module nopy.executor
|
||||
*/
|
||||
|
||||
import type { DependencySpec } from '@bitsquare/nopy-cube';
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { execa } from 'execa';
|
||||
import type { DependencySpec } from './cubes/types.js';
|
||||
import { MASK } from './nopy.common.js';
|
||||
|
||||
const log = getLogger(['nopy', 'executor']);
|
||||
|
||||
@@ -23,10 +24,47 @@ export interface DeployCall {
|
||||
command: string[];
|
||||
/** Environment variables for the cube */
|
||||
env: Record<string, unknown>;
|
||||
/** Schema keys the cube's manifest declared as secrets */
|
||||
secrets?: string[];
|
||||
/** Cube dependencies */
|
||||
dependencies: DependencySpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The command as it is safe to show: the SSH password, and every `--data KEY=…`
|
||||
* whose key the manifest declared a secret, have their values replaced.
|
||||
*
|
||||
* pyinfra takes its data on the command line, so the real values have to be in
|
||||
* `call.command` — this is the last point before they would reach a log, a
|
||||
* `--print-only` dump or a dry-run plan.
|
||||
*/
|
||||
export function maskCommand(call: DeployCall): string {
|
||||
const command = call.command.join(' ');
|
||||
const quoteMeta = (key: string) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// The builder always quotes a `--data` value, so the closing quote bounds it.
|
||||
const masked = (call.secrets ?? []).reduce(
|
||||
(acc, key) => acc.replace(new RegExp(`(--data "${quoteMeta(key)}=)[^"]*"`, 'g'), `$1${MASK}"`),
|
||||
command
|
||||
);
|
||||
|
||||
return masked.replace(/(--password )\S+/g, `$1${MASK}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cube's variables as they are safe to show.
|
||||
*
|
||||
* This used to guess, masking any key whose name contained "password" — which
|
||||
* missed `TOKEN` and `PSK`, and was defeated anyway by the unmasked command
|
||||
* printed on the line above it. The manifest says which keys are secret now.
|
||||
*/
|
||||
export function maskVariables(call: DeployCall): Record<string, string> {
|
||||
const secrets = new Set(call.secrets ?? []);
|
||||
return Object.fromEntries(
|
||||
Object.entries(call.env).map(([key, value]) => [key, secrets.has(key) ? MASK : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a deployment command
|
||||
*/
|
||||
@@ -73,7 +111,7 @@ async function executeCall(call: DeployCall): Promise<ExecutionResult> {
|
||||
|
||||
try {
|
||||
log.info(`Executing: ${call.cube} -> ${call.host}`);
|
||||
log.debug(`Command: ${commandStr}`);
|
||||
log.debug(`Command: ${maskCommand(call)}`);
|
||||
|
||||
// Inherit stdio for live output
|
||||
await execa({ shell: true })(commandStr, {
|
||||
@@ -112,8 +150,8 @@ export function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void
|
||||
const plan = calls.map((call) => ({
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
command: call.command.join(' '),
|
||||
variables: call.env,
|
||||
command: maskCommand(call),
|
||||
variables: maskVariables(call),
|
||||
}));
|
||||
console.log(JSON.stringify({ plan }, null, 2));
|
||||
return;
|
||||
@@ -124,15 +162,13 @@ export function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
console.log(`Step ${i + 1}: ${call.cube} -> ${call.host}`);
|
||||
console.log(` Command: ${call.command.join(' ')}`);
|
||||
console.log(` Command: ${maskCommand(call)}`);
|
||||
|
||||
const envKeys = Object.keys(call.env);
|
||||
if (envKeys.length > 0) {
|
||||
const variables = maskVariables(call);
|
||||
if (Object.keys(variables).length > 0) {
|
||||
console.log(' Variables:');
|
||||
for (const [key, value] of Object.entries(call.env)) {
|
||||
// Mask sensitive values
|
||||
const displayValue = key.toLowerCase().includes('password') ? '********' : String(value);
|
||||
console.log(` ${key}=${displayValue}`);
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
console.log(` ${key}=${value}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
@@ -8,7 +8,12 @@ import { BuildContext } from './cubes/dependencies.js';
|
||||
import { loadCubes } from './cubes/index.js';
|
||||
import { Variables } from './nopy.common.js';
|
||||
import { getConfigPaths, loadConfig } from './nopy.config.js';
|
||||
import { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js';
|
||||
import {
|
||||
type ExecutionResult,
|
||||
executeDeployCalls,
|
||||
maskCommand,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
import { addToHistory, DEFAULT_HISTORY_SIZE } from './nopy.history.js';
|
||||
import { type NopySession, saveSession } from './nopy.session.js';
|
||||
import { runWorkflow } from './nopy.workflow.js';
|
||||
@@ -72,6 +77,9 @@ function printActiveConfig(
|
||||
|
||||
if (config.hosts.length > 0) lines.push(` Hosts: ${config.hosts.join(', ')}`);
|
||||
if (config.cubeDirs.length > 0) lines.push(` Cube dirs: ${config.cubeDirs.join(', ')}`);
|
||||
if (config.cubePackages.length > 0) {
|
||||
lines.push(` Cube pkgs: ${config.cubePackages.map((ref) => ref.spec).join(', ')}`);
|
||||
}
|
||||
if (opts.continueOnError) lines.push(' Execution: continue-on-error');
|
||||
|
||||
const envEntries = Object.entries(config.env);
|
||||
@@ -185,7 +193,7 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
|
||||
const sessionForSaving: NopySession = {
|
||||
...workflow.session,
|
||||
cubes: context.cubeSessions,
|
||||
env: variables.get('global'),
|
||||
env: config.env,
|
||||
};
|
||||
|
||||
if (saveSessionPath && !workflow.isReplay) {
|
||||
@@ -203,7 +211,7 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
|
||||
console.log('\n Deploy Commands\n ───────────────\n');
|
||||
for (const call of context.deployCalls) {
|
||||
console.log(` # ${call.cube} -> ${call.host}`);
|
||||
console.log(` ${call.command.join(' ')}\n`);
|
||||
console.log(` ${maskCommand(call)}\n`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -36,11 +36,17 @@ function suggestCubes(input: string | undefined, choices: CubeChoice[]): CubeCho
|
||||
export async function CubeSelection(
|
||||
cubes: Record<string, Cube>
|
||||
): Promise<{ selectedCubes: string[] }> {
|
||||
// The package a cube came from is part of the label rather than a separate
|
||||
// column: `suggest` filters on the label, so typing a package name narrows
|
||||
// the list to that bundle.
|
||||
const cubeChoices: CubeChoice[] = Object.values(cubes)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((cube) => ({
|
||||
name: cube.id,
|
||||
message: `${cube.id} - ${cube.name}`,
|
||||
message:
|
||||
cube.source.type === 'package'
|
||||
? `${cube.id} - ${cube.name} (${cube.source.packageName})`
|
||||
: `${cube.id} - ${cube.name}`,
|
||||
}));
|
||||
|
||||
// Clear terminal and move cursor to top
|
||||
@@ -176,24 +182,35 @@ interface FormChoice {
|
||||
initial: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user for a cube's variables and records the answers.
|
||||
*
|
||||
* Reads what to offer out of `variables`, so the caller is expected to have
|
||||
* assigned the schema defaults first — which `BuildContext.resolveCube` does.
|
||||
* Deliberately not falling back to `cube.getDefaults()` here: calling it a
|
||||
* second time re-evaluates every lazily declared default, so a cube generating
|
||||
* one would show a different value than the one the run had already recorded.
|
||||
*/
|
||||
export async function VariableAssignment<S extends AnyObjectSchema>(
|
||||
cube: Cube<S>,
|
||||
variables: Variables
|
||||
variables: Variables,
|
||||
opts: { keys?: string[] } = {}
|
||||
) {
|
||||
const schema = cube.manifest.schema.shape;
|
||||
const defaults = cube.getDefaults() as Record<string, unknown>;
|
||||
const params = variables.get(cube.id, 'params');
|
||||
const resolved = variables.get(cube.id);
|
||||
const variablesToConfigure: Record<string, unknown> = {};
|
||||
|
||||
// Every schema key is offered, not just the ones carrying a `.default()` — a
|
||||
// field without one is precisely the field that has to be asked about. Keys a
|
||||
// dependency or hook already supplied are left alone. The value shown is the
|
||||
// Every schema key is offered by default, not just the ones carrying a
|
||||
// `.default()` — a field without one is precisely the field that has to be
|
||||
// asked about. `opts.keys` narrows that to a subset, which is how a replay
|
||||
// asks only about the gaps it cannot fill itself.
|
||||
//
|
||||
// A key a dependency or hook supplied is left alone. The value shown is the
|
||||
// one the run would otherwise use, so `env` from `.nopyrc.json` is visible
|
||||
// (and editable) rather than silently overridden by whatever is typed.
|
||||
for (const key of Object.keys(schema)) {
|
||||
if (params[key] !== undefined) continue;
|
||||
variablesToConfigure[key] = resolved[key] ?? defaults[key];
|
||||
for (const key of opts.keys ?? Object.keys(schema)) {
|
||||
if (variables.of(cube.id, key)?.origin === 'param') continue;
|
||||
variablesToConfigure[key] = resolved[key];
|
||||
}
|
||||
|
||||
if (Object.keys(variablesToConfigure).length === 0) return;
|
||||
@@ -217,7 +234,7 @@ export async function VariableAssignment<S extends AnyObjectSchema>(
|
||||
const zodType = schema[key];
|
||||
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
|
||||
}
|
||||
variables.assign(cube.id, 'prompts', coercedResult);
|
||||
variables.assign(cube.id, 'prompt', coercedResult);
|
||||
} catch {
|
||||
// User cancelled
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user