boxman 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boxman/__init__.py +0 -0
- boxman/cli.py +135 -0
- boxman/data/__init__.py +0 -0
- boxman/data/ec2-template.yaml +126 -0
- boxman/data/linux-tools.toml +80 -0
- boxman/ec2.py +297 -0
- boxman/system.py +179 -0
- boxman/user.py +190 -0
- boxman/verify.py +66 -0
- boxman-0.1.0.dist-info/METADATA +169 -0
- boxman-0.1.0.dist-info/RECORD +14 -0
- boxman-0.1.0.dist-info/WHEEL +5 -0
- boxman-0.1.0.dist-info/entry_points.txt +2 -0
- boxman-0.1.0.dist-info/top_level.txt +1 -0
boxman/__init__.py
ADDED
|
File without changes
|
boxman/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Set up and manage a Linux development box."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fail(message: str) -> None:
|
|
13
|
+
raise SystemExit(message)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(*args: str) -> None:
|
|
17
|
+
result = subprocess.run(args, check=False)
|
|
18
|
+
if result.returncode:
|
|
19
|
+
raise SystemExit(result.returncode)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def mounted(path: Path) -> bool:
|
|
23
|
+
return subprocess.run(
|
|
24
|
+
("mountpoint", "-q", str(path)),
|
|
25
|
+
stdout=subprocess.DEVNULL,
|
|
26
|
+
stderr=subprocess.DEVNULL,
|
|
27
|
+
check=False,
|
|
28
|
+
).returncode == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def vault_paths() -> tuple[Path, Path]:
|
|
32
|
+
home = Path.home()
|
|
33
|
+
cipher, plain = home / ".private.cipher", home / "private"
|
|
34
|
+
if cipher.is_symlink() or plain.is_symlink():
|
|
35
|
+
fail("Private vault paths must not be symlinks")
|
|
36
|
+
return cipher, plain
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def private_dir(path: Path) -> None:
|
|
40
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
41
|
+
path.chmod(0o700)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def vault(action: str) -> None:
|
|
45
|
+
if os.geteuid() == 0:
|
|
46
|
+
fail("Run vault commands as your login user, not root")
|
|
47
|
+
os.umask(0o077)
|
|
48
|
+
cipher, plain = vault_paths()
|
|
49
|
+
|
|
50
|
+
if action == "status":
|
|
51
|
+
print("Private vault is unlocked" if mounted(plain) else "Private vault is locked")
|
|
52
|
+
elif action == "init":
|
|
53
|
+
if mounted(plain):
|
|
54
|
+
fail("Private vault is already unlocked")
|
|
55
|
+
if (cipher / "gocryptfs.conf").exists():
|
|
56
|
+
fail("Private vault is already initialized")
|
|
57
|
+
private_dir(cipher)
|
|
58
|
+
private_dir(plain)
|
|
59
|
+
if any(cipher.iterdir()):
|
|
60
|
+
fail("Encrypted backing directory is not empty")
|
|
61
|
+
if any(plain.iterdir()):
|
|
62
|
+
fail("Plaintext mount directory is not empty")
|
|
63
|
+
run("gocryptfs", "-init", str(cipher))
|
|
64
|
+
print("Keep the password and recovery key outside this host.")
|
|
65
|
+
elif action == "unlock":
|
|
66
|
+
if not (cipher / "gocryptfs.conf").is_file():
|
|
67
|
+
fail("Private vault is not initialized; run boxman vault init")
|
|
68
|
+
private_dir(cipher)
|
|
69
|
+
private_dir(plain)
|
|
70
|
+
if mounted(plain):
|
|
71
|
+
print("Private vault is already unlocked")
|
|
72
|
+
return
|
|
73
|
+
if any(plain.iterdir()):
|
|
74
|
+
fail("Plaintext mount directory is not empty; refusing to hide its files")
|
|
75
|
+
run("gocryptfs", str(cipher), str(plain))
|
|
76
|
+
elif action == "lock":
|
|
77
|
+
if not mounted(plain):
|
|
78
|
+
print("Private vault is already locked")
|
|
79
|
+
return
|
|
80
|
+
run("fusermount3", "-u", str(plain))
|
|
81
|
+
else:
|
|
82
|
+
fail("Usage: boxman vault {init|unlock|lock|status}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def claude(args: list[str]) -> None:
|
|
86
|
+
if os.geteuid() == 0:
|
|
87
|
+
fail("Run Claude as your login user, not root")
|
|
88
|
+
_, plain = vault_paths()
|
|
89
|
+
if not mounted(plain):
|
|
90
|
+
fail("Private vault is locked; run boxman vault unlock first")
|
|
91
|
+
config = plain / "claude"
|
|
92
|
+
if config.is_symlink():
|
|
93
|
+
fail("Claude config directory must not be a symlink")
|
|
94
|
+
os.umask(0o077)
|
|
95
|
+
private_dir(config)
|
|
96
|
+
os.environ["CLAUDE_CONFIG_DIR"] = str(config)
|
|
97
|
+
os.execvp("claude", ["claude", *args])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
if len(sys.argv) < 2:
|
|
102
|
+
fail("Usage: boxman {system|user|vault|claude|verify|ec2} ...")
|
|
103
|
+
command, *args = sys.argv[1:]
|
|
104
|
+
if command == "system":
|
|
105
|
+
from boxman import system
|
|
106
|
+
|
|
107
|
+
system.main(args)
|
|
108
|
+
elif command == "user":
|
|
109
|
+
if args:
|
|
110
|
+
fail("Usage: boxman user")
|
|
111
|
+
from boxman import user
|
|
112
|
+
|
|
113
|
+
user.main()
|
|
114
|
+
elif command == "vault":
|
|
115
|
+
if len(args) != 1:
|
|
116
|
+
fail("Usage: boxman vault {init|unlock|lock|status}")
|
|
117
|
+
vault(args[0])
|
|
118
|
+
elif command == "claude":
|
|
119
|
+
claude(args)
|
|
120
|
+
elif command == "ec2":
|
|
121
|
+
from boxman import ec2
|
|
122
|
+
|
|
123
|
+
ec2.main(args)
|
|
124
|
+
elif command == "verify":
|
|
125
|
+
if args:
|
|
126
|
+
fail("Usage: boxman verify")
|
|
127
|
+
from boxman import verify
|
|
128
|
+
|
|
129
|
+
verify.main()
|
|
130
|
+
else:
|
|
131
|
+
fail("Usage: boxman {system|user|vault|claude|verify|ec2} ...")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
boxman/data/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: "2010-09-09"
|
|
2
|
+
Description: >
|
|
3
|
+
EC2 development box:
|
|
4
|
+
one instance, no inbound security-group rules, SSM-only access.
|
|
5
|
+
|
|
6
|
+
Parameters:
|
|
7
|
+
VpcId:
|
|
8
|
+
Type: AWS::EC2::VPC::Id
|
|
9
|
+
SubnetId:
|
|
10
|
+
Type: AWS::EC2::Subnet::Id
|
|
11
|
+
InstanceType:
|
|
12
|
+
Type: String
|
|
13
|
+
VolumeSizeGb:
|
|
14
|
+
Type: Number
|
|
15
|
+
InstanceName:
|
|
16
|
+
Type: String
|
|
17
|
+
LatestAmiId:
|
|
18
|
+
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
|
|
19
|
+
|
|
20
|
+
Resources:
|
|
21
|
+
SecurityGroup:
|
|
22
|
+
Type: AWS::EC2::SecurityGroup
|
|
23
|
+
Properties:
|
|
24
|
+
GroupName: !Sub "${AWS::StackName}-sg"
|
|
25
|
+
GroupDescription: Shared coding-agent dev box - no inbound rules, SSM only
|
|
26
|
+
VpcId: !Ref VpcId
|
|
27
|
+
Tags:
|
|
28
|
+
- Key: Name
|
|
29
|
+
Value: !Sub "${AWS::StackName}-sg"
|
|
30
|
+
# No SecurityGroupIngress here: no inbound rules from the internet, on
|
|
31
|
+
# purpose. The only inbound access is via SecurityGroupIngressFromEice
|
|
32
|
+
# below, scoped to the Instance Connect Endpoint's own security group.
|
|
33
|
+
# No SecurityGroupEgress: keeps CloudFormation's default allow-all outbound.
|
|
34
|
+
|
|
35
|
+
EiceSecurityGroup:
|
|
36
|
+
Type: AWS::EC2::SecurityGroup
|
|
37
|
+
Properties:
|
|
38
|
+
GroupName: !Sub "${AWS::StackName}-eice-sg"
|
|
39
|
+
GroupDescription: EC2 Instance Connect Endpoint - no inbound rules needed
|
|
40
|
+
VpcId: !Ref VpcId
|
|
41
|
+
Tags:
|
|
42
|
+
- Key: Name
|
|
43
|
+
Value: !Sub "${AWS::StackName}-eice-sg"
|
|
44
|
+
|
|
45
|
+
SecurityGroupIngressFromEice:
|
|
46
|
+
Type: AWS::EC2::SecurityGroupIngress
|
|
47
|
+
Properties:
|
|
48
|
+
GroupId: !Ref SecurityGroup
|
|
49
|
+
IpProtocol: tcp
|
|
50
|
+
FromPort: 22
|
|
51
|
+
ToPort: 22
|
|
52
|
+
SourceSecurityGroupId: !Ref EiceSecurityGroup
|
|
53
|
+
Description: SSH from the EC2 Instance Connect Endpoint only
|
|
54
|
+
|
|
55
|
+
InstanceConnectEndpoint:
|
|
56
|
+
Type: AWS::EC2::InstanceConnectEndpoint
|
|
57
|
+
Properties:
|
|
58
|
+
SubnetId: !Ref SubnetId
|
|
59
|
+
SecurityGroupIds:
|
|
60
|
+
- !Ref EiceSecurityGroup
|
|
61
|
+
PreserveClientIp: true
|
|
62
|
+
Tags:
|
|
63
|
+
- Key: Name
|
|
64
|
+
Value: !Sub "${AWS::StackName}-eice"
|
|
65
|
+
|
|
66
|
+
InstanceRole:
|
|
67
|
+
Type: AWS::IAM::Role
|
|
68
|
+
Properties:
|
|
69
|
+
RoleName: !Sub "${AWS::StackName}-ssm-role"
|
|
70
|
+
AssumeRolePolicyDocument:
|
|
71
|
+
Version: "2012-10-17"
|
|
72
|
+
Statement:
|
|
73
|
+
- Effect: Allow
|
|
74
|
+
Principal:
|
|
75
|
+
Service: ec2.amazonaws.com
|
|
76
|
+
Action: sts:AssumeRole
|
|
77
|
+
ManagedPolicyArns:
|
|
78
|
+
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
|
|
79
|
+
Tags:
|
|
80
|
+
- Key: Name
|
|
81
|
+
Value: !Sub "${AWS::StackName}-ssm-role"
|
|
82
|
+
|
|
83
|
+
InstanceProfile:
|
|
84
|
+
Type: AWS::IAM::InstanceProfile
|
|
85
|
+
Properties:
|
|
86
|
+
InstanceProfileName: !Sub "${AWS::StackName}-ssm-profile"
|
|
87
|
+
Roles:
|
|
88
|
+
- !Ref InstanceRole
|
|
89
|
+
|
|
90
|
+
Instance:
|
|
91
|
+
Type: AWS::EC2::Instance
|
|
92
|
+
Properties:
|
|
93
|
+
ImageId: !Ref LatestAmiId
|
|
94
|
+
InstanceType: !Ref InstanceType
|
|
95
|
+
SubnetId: !Ref SubnetId
|
|
96
|
+
SecurityGroupIds:
|
|
97
|
+
- !Ref SecurityGroup
|
|
98
|
+
IamInstanceProfile: !Ref InstanceProfile
|
|
99
|
+
BlockDeviceMappings:
|
|
100
|
+
- DeviceName: /dev/sda1
|
|
101
|
+
Ebs:
|
|
102
|
+
VolumeSize: !Ref VolumeSizeGb
|
|
103
|
+
VolumeType: gp3
|
|
104
|
+
DeleteOnTermination: true
|
|
105
|
+
MetadataOptions:
|
|
106
|
+
HttpTokens: required
|
|
107
|
+
HttpPutResponseHopLimit: 2
|
|
108
|
+
Tags:
|
|
109
|
+
- Key: Name
|
|
110
|
+
Value: !Ref InstanceName
|
|
111
|
+
|
|
112
|
+
Outputs:
|
|
113
|
+
InstanceId:
|
|
114
|
+
Value: !Ref Instance
|
|
115
|
+
SecurityGroupId:
|
|
116
|
+
Value: !Ref SecurityGroup
|
|
117
|
+
InstanceRoleArn:
|
|
118
|
+
Value: !GetAtt InstanceRole.Arn
|
|
119
|
+
InstanceProfileArn:
|
|
120
|
+
Value: !GetAtt InstanceProfile.Arn
|
|
121
|
+
# No PublicIp output: !GetAtt Instance.PublicIp fails outright whenever the
|
|
122
|
+
# instance isn't running (stopped by uptime governance, mid-restart, etc.),
|
|
123
|
+
# breaking every subsequent stack operation. basbox.py status() already
|
|
124
|
+
# reads the live public IP via ec2:DescribeInstances instead.
|
|
125
|
+
InstanceConnectEndpointId:
|
|
126
|
+
Value: !Ref InstanceConnectEndpoint
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# zipget recipe for boxman on Ubuntu 24.04
|
|
2
|
+
|
|
3
|
+
[system_packages]
|
|
4
|
+
apt = [
|
|
5
|
+
"acl", "build-essential", "ca-certificates", "curl", "dbus-user-session",
|
|
6
|
+
"direnv", "file", "fuse3", "fuse-overlayfs", "gh", "git", "git-lfs",
|
|
7
|
+
"gocryptfs", "jq",
|
|
8
|
+
"less", "lf", "libsecret-tools", "openssh-client", "podman", "python3",
|
|
9
|
+
"python3-venv", "python-is-python3", "rsync", "shellcheck",
|
|
10
|
+
"slirp4netns", "uidmap", "unzip", "xz-utils", "zip", "zstd",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[vars]
|
|
14
|
+
bin_dir = "~/.local/bin"
|
|
15
|
+
|
|
16
|
+
[herdr]
|
|
17
|
+
# Agent multiplexer - https://herdr.dev
|
|
18
|
+
github = { repo = "herdrdev/herdr", asset = "herdr-linux-x86_64" }
|
|
19
|
+
save_as = "${bin_dir}/herdr"
|
|
20
|
+
executable = true
|
|
21
|
+
|
|
22
|
+
[cship]
|
|
23
|
+
# Claude Code statusline - https://cship.dev
|
|
24
|
+
github = { repo = "stephenleo/cship", asset = "cship-x86_64-unknown-linux-musl" }
|
|
25
|
+
save_as = "${bin_dir}/cship"
|
|
26
|
+
executable = true
|
|
27
|
+
|
|
28
|
+
[microsoft-edit]
|
|
29
|
+
# Terminal-based text editor - https://github.com/microsoft/edit
|
|
30
|
+
# Note: only gnu builds available (no musl)
|
|
31
|
+
github = { repo = "microsoft/edit", asset = "x86_64-linux-gnu.tar.gz" }
|
|
32
|
+
unzip_to = "${bin_dir}"
|
|
33
|
+
files = "edit"
|
|
34
|
+
|
|
35
|
+
[starship]
|
|
36
|
+
# Cross-shell prompt - https://github.com/starship/starship
|
|
37
|
+
github = { repo = "starship/starship", asset = "x86_64-unknown-linux-musl.tar.gz" }
|
|
38
|
+
unzip_to = "${bin_dir}"
|
|
39
|
+
files = "starship"
|
|
40
|
+
|
|
41
|
+
[ripgrep]
|
|
42
|
+
# Fast regex search tool - https://github.com/BurntSushi/ripgrep
|
|
43
|
+
github = { repo = "BurntSushi/ripgrep", asset = "x86_64-unknown-linux-musl.tar.gz" }
|
|
44
|
+
unzip_to = "${bin_dir}"
|
|
45
|
+
files = "*/rg"
|
|
46
|
+
|
|
47
|
+
[fd]
|
|
48
|
+
# Fast find alternative - https://github.com/sharkdp/fd
|
|
49
|
+
github = { repo = "sharkdp/fd", asset = "x86_64-unknown-linux-musl.tar.gz" }
|
|
50
|
+
unzip_to = "${bin_dir}"
|
|
51
|
+
files = "*/fd"
|
|
52
|
+
|
|
53
|
+
[bat]
|
|
54
|
+
# Cat with syntax highlighting - https://github.com/sharkdp/bat
|
|
55
|
+
github = { repo = "sharkdp/bat", asset = "x86_64-unknown-linux-musl.tar.gz" }
|
|
56
|
+
unzip_to = "${bin_dir}"
|
|
57
|
+
files = "*/bat"
|
|
58
|
+
|
|
59
|
+
[fzf]
|
|
60
|
+
# Fuzzy finder - https://github.com/junegunn/fzf
|
|
61
|
+
github = { repo = "junegunn/fzf", asset = "linux_amd64.tar.gz" }
|
|
62
|
+
unzip_to = "${bin_dir}"
|
|
63
|
+
files = "fzf"
|
|
64
|
+
|
|
65
|
+
[zoxide]
|
|
66
|
+
# Smarter cd command - https://github.com/ajeetdsouza/zoxide
|
|
67
|
+
github = { repo = "ajeetdsouza/zoxide", asset = "x86_64-unknown-linux-musl.tar.gz" }
|
|
68
|
+
unzip_to = "${bin_dir}"
|
|
69
|
+
files = "zoxide"
|
|
70
|
+
|
|
71
|
+
[delta]
|
|
72
|
+
# Git diff viewer - https://github.com/dandavison/delta
|
|
73
|
+
github = { repo = "dandavison/delta", asset = "x86_64-unknown-linux-musl.tar.gz" }
|
|
74
|
+
unzip_to = "${bin_dir}"
|
|
75
|
+
files = "*/delta"
|
|
76
|
+
|
|
77
|
+
[aws-cli]
|
|
78
|
+
# AWS CLI v2 - official installer
|
|
79
|
+
url = "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"
|
|
80
|
+
unzip_to = "./aws-cli-installer"
|
boxman/ec2.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Manage an EC2 development box through a CloudFormation stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import base64
|
|
7
|
+
import contextlib
|
|
8
|
+
import importlib.resources
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PARAMETERS = {
|
|
19
|
+
"vpc_id": "VpcId",
|
|
20
|
+
"subnet_id": "SubnetId",
|
|
21
|
+
"instance_type": "InstanceType",
|
|
22
|
+
"volume_size_gb": "VolumeSizeGb",
|
|
23
|
+
"instance_name": "InstanceName",
|
|
24
|
+
"ami_id": "LatestAmiId",
|
|
25
|
+
}
|
|
26
|
+
USERNAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}\Z")
|
|
27
|
+
ALIAS = re.compile(r"[A-Za-z0-9_.-]+\Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def required(value: str | None, name: str) -> str:
|
|
31
|
+
if not value:
|
|
32
|
+
raise SystemExit(f"Missing {name}; provide it by option or --config")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def username(value: str) -> str:
|
|
37
|
+
if not USERNAME.fullmatch(value):
|
|
38
|
+
raise SystemExit(f"Invalid Unix username: {value!r}")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_dir() -> Path:
|
|
43
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "boxman"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def stack_template(name: str) -> Path:
|
|
47
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]{0,127}", name):
|
|
48
|
+
raise SystemExit("Stack name must be a valid CloudFormation stack name")
|
|
49
|
+
return config_dir() / "stacks" / f"{name}.yaml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def init_stack(name: str, values: dict) -> Path:
|
|
53
|
+
target = stack_template(name)
|
|
54
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
55
|
+
body = importlib.resources.files("boxman").joinpath("data/ec2-template.yaml").read_text()
|
|
56
|
+
for key, parameter in PARAMETERS.items():
|
|
57
|
+
value = required(values.get(key), "--" + key.replace("_", "-"))
|
|
58
|
+
marker = f" {parameter}:\n Type: "
|
|
59
|
+
match = re.search(re.escape(marker) + r"[^\n]+\n", body)
|
|
60
|
+
if not match:
|
|
61
|
+
raise SystemExit(f"Template is missing parameter {parameter}")
|
|
62
|
+
body = body[:match.end()] + f" Default: {json.dumps(str(value))}\n" + body[match.end():]
|
|
63
|
+
try:
|
|
64
|
+
with target.open("x") as output:
|
|
65
|
+
output.write(body)
|
|
66
|
+
except FileExistsError:
|
|
67
|
+
raise SystemExit(f"Stack template already exists: {target}") from None
|
|
68
|
+
return target
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def settings(args: argparse.Namespace) -> dict:
|
|
72
|
+
config = {}
|
|
73
|
+
config_path = args.config or config_dir() / "ec2.toml"
|
|
74
|
+
if config_path.is_file():
|
|
75
|
+
with config_path.open("rb") as source:
|
|
76
|
+
config = tomllib.load(source)
|
|
77
|
+
elif args.config:
|
|
78
|
+
raise SystemExit(f"Config file does not exist: {config_path}")
|
|
79
|
+
if config:
|
|
80
|
+
if set(config) - {"ec2"} or not isinstance(config.get("ec2"), dict):
|
|
81
|
+
raise SystemExit("Config must contain an [ec2] table")
|
|
82
|
+
config = config["ec2"]
|
|
83
|
+
keys = {"profile", "region", "stack_name", "tags"}
|
|
84
|
+
if set(config) - keys:
|
|
85
|
+
raise SystemExit(f"Unknown [ec2] settings: {', '.join(sorted(set(config) - keys))}")
|
|
86
|
+
result = {key: getattr(args, key, None) or config.get(key) for key in keys}
|
|
87
|
+
result["stack_name"] = required(result["stack_name"], "--stack-name")
|
|
88
|
+
stack_template(result["stack_name"])
|
|
89
|
+
if args.action != "init":
|
|
90
|
+
result["profile"] = required(result["profile"], "--profile")
|
|
91
|
+
result["region"] = required(result["region"], "--region")
|
|
92
|
+
tags = config.get("tags", {}).copy()
|
|
93
|
+
if not isinstance(tags, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in tags.items()):
|
|
94
|
+
raise SystemExit("[ec2.tags] must contain string keys and values")
|
|
95
|
+
for item in getattr(args, "tag", None) or []:
|
|
96
|
+
if "=" not in item or not item.split("=", 1)[0]:
|
|
97
|
+
raise SystemExit("--tag must be KEY=VALUE")
|
|
98
|
+
key, value = item.split("=", 1)
|
|
99
|
+
tags[key] = value
|
|
100
|
+
result["tags"] = tags
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stack(cfn, name: str):
|
|
105
|
+
try:
|
|
106
|
+
return cfn.describe_stacks(StackName=name)["Stacks"][0]
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
if getattr(exc, "response", {}).get("Error", {}).get("Code") == "ValidationError" and "does not exist" in str(exc):
|
|
109
|
+
return None
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def instance_id(cfn, name: str) -> str:
|
|
114
|
+
current = stack(cfn, name)
|
|
115
|
+
if current:
|
|
116
|
+
for item in current.get("Outputs", []):
|
|
117
|
+
if item["OutputKey"] == "InstanceId":
|
|
118
|
+
return item["OutputValue"]
|
|
119
|
+
raise SystemExit(f"Stack {name} has no InstanceId output; deploy it first")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def execute(ssm, instance: str, command: str, user: str | None = None) -> None:
|
|
123
|
+
if user:
|
|
124
|
+
user = username(user)
|
|
125
|
+
payload = base64.b64encode(command.encode()).decode()
|
|
126
|
+
command = (f"BOXMAN_TMP=$(mktemp)\ntrap 'rm -f \"$BOXMAN_TMP\"' EXIT\n"
|
|
127
|
+
f"echo {shlex.quote(payload)} | base64 -d > \"$BOXMAN_TMP\"\n"
|
|
128
|
+
f"chmod 755 \"$BOXMAN_TMP\"\nsudo -iu {user} bash \"$BOXMAN_TMP\"")
|
|
129
|
+
response = ssm.send_command(InstanceIds=[instance], DocumentName="AWS-RunShellScript", Parameters={"commands": [command]})
|
|
130
|
+
command_id = response["Command"]["CommandId"]
|
|
131
|
+
with contextlib.suppress(Exception):
|
|
132
|
+
ssm.get_waiter("command_executed").wait(CommandId=command_id, InstanceId=instance)
|
|
133
|
+
result = ssm.get_command_invocation(CommandId=command_id, InstanceId=instance)
|
|
134
|
+
print(f"Status: {result['Status']}")
|
|
135
|
+
for key in ("StandardOutputContent", "StandardErrorContent"):
|
|
136
|
+
if result.get(key):
|
|
137
|
+
print(result[key], end="" if result[key].endswith("\n") else "\n")
|
|
138
|
+
if result["Status"] != "Success":
|
|
139
|
+
raise SystemExit(f"Remote command failed: {result['Status']}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def add_key(ssm, instance: str, user: str, key_path: Path) -> None:
|
|
143
|
+
user = username(user)
|
|
144
|
+
key = key_path.read_text().strip()
|
|
145
|
+
if "\n" in key or not key.startswith(("ssh-ed25519 ", "ssh-rsa ", "ecdsa-sha2-")):
|
|
146
|
+
raise SystemExit(f"Invalid public key: {key_path}")
|
|
147
|
+
payload = base64.b64encode(key.encode()).decode()
|
|
148
|
+
script = ("set -e\nmkdir -p ~/.ssh\nchmod 700 ~/.ssh\n"
|
|
149
|
+
"touch ~/.ssh/authorized_keys\nchmod 600 ~/.ssh/authorized_keys\n"
|
|
150
|
+
f"KEY=$(echo {shlex.quote(payload)} | base64 -d)\n"
|
|
151
|
+
"grep -qxF \"$KEY\" ~/.ssh/authorized_keys || printf '%s\\n' \"$KEY\" >> ~/.ssh/authorized_keys\n")
|
|
152
|
+
execute(ssm, instance, script, user)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def keypair(path: Path) -> None:
|
|
156
|
+
if path.exists():
|
|
157
|
+
if not Path(str(path) + ".pub").is_file():
|
|
158
|
+
raise SystemExit(f"Missing public key for {path}")
|
|
159
|
+
return
|
|
160
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
161
|
+
subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(path)], check=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def proxy(settings_: dict) -> str:
|
|
165
|
+
return " ".join(shlex.quote(part) for part in ("aws", "ec2-instance-connect", "open-tunnel", "--profile", settings_["profile"], "--region", settings_["region"], "--instance-id")) + " %h"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def write_ssh_config(alias: str, body: str) -> None:
|
|
169
|
+
if not ALIAS.fullmatch(alias):
|
|
170
|
+
raise SystemExit("Alias may contain letters, numbers, dot, underscore and hyphen")
|
|
171
|
+
path = Path.home() / ".ssh" / "config"
|
|
172
|
+
path.parent.mkdir(mode=0o700, exist_ok=True)
|
|
173
|
+
content = path.read_text() if path.exists() else ""
|
|
174
|
+
begin, end = f"# boxman:{alias} begin", f"# boxman:{alias} end"
|
|
175
|
+
block = f"{begin}\n{body}\n{end}\n"
|
|
176
|
+
pattern = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
|
|
177
|
+
content = pattern.sub(lambda _: block, content) if pattern.search(content) else content.rstrip("\n") + ("\n\n" if content else "") + block
|
|
178
|
+
path.write_text(content)
|
|
179
|
+
path.chmod(0o600)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str]) -> None:
|
|
183
|
+
parser = argparse.ArgumentParser(prog="boxman ec2")
|
|
184
|
+
parser.add_argument("--config", type=Path, help="TOML file (default: $XDG_CONFIG_HOME/boxman/ec2.toml)")
|
|
185
|
+
parser.add_argument("--profile")
|
|
186
|
+
parser.add_argument("--region")
|
|
187
|
+
parser.add_argument("--stack-name")
|
|
188
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
189
|
+
sub.add_parser("init", help="create a local stack template under the boxman config directory")
|
|
190
|
+
init = sub.choices["init"]
|
|
191
|
+
for key in PARAMETERS:
|
|
192
|
+
init.add_argument("--" + key.replace("_", "-"), dest=key)
|
|
193
|
+
deploy = sub.add_parser("deploy")
|
|
194
|
+
deploy.add_argument("--tag", action="append", help="stack tag as KEY=VALUE; repeatable")
|
|
195
|
+
for action in ("status", "start", "stop"):
|
|
196
|
+
sub.add_parser(action)
|
|
197
|
+
connect = sub.add_parser("connect")
|
|
198
|
+
connect.add_argument("-u", "--user")
|
|
199
|
+
ssh = sub.add_parser("ssh")
|
|
200
|
+
ssh.add_argument("-u", "--user", required=True)
|
|
201
|
+
ssh.add_argument("--key-path", type=Path)
|
|
202
|
+
ssh.add_argument("-c", "--container")
|
|
203
|
+
ssh_config = sub.add_parser("ssh-config")
|
|
204
|
+
ssh_config.add_argument("-u", "--user", required=True)
|
|
205
|
+
ssh_config.add_argument("--alias", required=True)
|
|
206
|
+
ssh_config.add_argument("--key-path", type=Path)
|
|
207
|
+
run = sub.add_parser("run")
|
|
208
|
+
run.add_argument("command")
|
|
209
|
+
run.add_argument("-u", "--user")
|
|
210
|
+
args = parser.parse_args(argv)
|
|
211
|
+
conf = settings(args)
|
|
212
|
+
if args.action == "init":
|
|
213
|
+
values = {key: getattr(args, key) for key in PARAMETERS}
|
|
214
|
+
try:
|
|
215
|
+
if int(required(values["volume_size_gb"], "--volume-size-gb")) < 8:
|
|
216
|
+
raise SystemExit("--volume-size-gb must be at least 8")
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise SystemExit("--volume-size-gb must be an integer") from exc
|
|
219
|
+
print(f"Created {init_stack(conf['stack_name'], values)}")
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
import boto3
|
|
223
|
+
import botocore.exceptions
|
|
224
|
+
except ImportError as exc:
|
|
225
|
+
raise SystemExit("EC2 commands require boto3; install boxman with the ec2 extra") from exc
|
|
226
|
+
try:
|
|
227
|
+
session = boto3.Session(profile_name=conf["profile"], region_name=conf["region"])
|
|
228
|
+
cfn = session.client("cloudformation")
|
|
229
|
+
name = conf["stack_name"]
|
|
230
|
+
if args.action == "deploy":
|
|
231
|
+
template = stack_template(name)
|
|
232
|
+
if not template.is_file():
|
|
233
|
+
raise SystemExit(f"Stack template missing: {template}; run boxman ec2 init first")
|
|
234
|
+
body = template.read_text()
|
|
235
|
+
params = []
|
|
236
|
+
for aws in PARAMETERS.values():
|
|
237
|
+
match = re.search(rf"^ {re.escape(aws)}:\n Type: [^\n]+\n Default: (.+)$", body, re.MULTILINE)
|
|
238
|
+
if not match:
|
|
239
|
+
raise SystemExit(f"Stack template lacks a default for {aws}: {template}")
|
|
240
|
+
try:
|
|
241
|
+
value = json.loads(match.group(1))
|
|
242
|
+
except json.JSONDecodeError as exc:
|
|
243
|
+
raise SystemExit(f"Invalid default for {aws}: {exc}") from exc
|
|
244
|
+
params.append({"ParameterKey": aws, "ParameterValue": str(value)})
|
|
245
|
+
tags = [{"Key": key, "Value": value} for key, value in conf["tags"].items()]
|
|
246
|
+
current = stack(cfn, name)
|
|
247
|
+
method = cfn.update_stack if current else cfn.create_stack
|
|
248
|
+
try:
|
|
249
|
+
method(StackName=name, TemplateBody=body, Parameters=params, Capabilities=["CAPABILITY_NAMED_IAM"], Tags=tags)
|
|
250
|
+
except botocore.exceptions.ClientError as exc:
|
|
251
|
+
if current and "No updates are to be performed" in str(exc):
|
|
252
|
+
print("No changes to apply.")
|
|
253
|
+
return
|
|
254
|
+
raise
|
|
255
|
+
cfn.get_waiter("stack_update_complete" if current else "stack_create_complete").wait(StackName=name)
|
|
256
|
+
print(f"InstanceId: {instance_id(cfn, name)}")
|
|
257
|
+
return
|
|
258
|
+
instance = instance_id(cfn, name)
|
|
259
|
+
if args.action == "status":
|
|
260
|
+
ec2 = session.client("ec2")
|
|
261
|
+
info = ec2.describe_instances(InstanceIds=[instance])["Reservations"][0]["Instances"][0]
|
|
262
|
+
print(f"Instance {instance}: {info['State']['Name']}, {info.get('PublicIpAddress', 'no public IP')}")
|
|
263
|
+
ssm = session.client("ssm")
|
|
264
|
+
records = ssm.describe_instance_information(Filters=[{"Key": "InstanceIds", "Values": [instance]}])["InstanceInformationList"]
|
|
265
|
+
print(f"SSM: {records[0]['PingStatus'] if records else 'not registered'}")
|
|
266
|
+
elif args.action in ("start", "stop"):
|
|
267
|
+
ec2 = session.client("ec2")
|
|
268
|
+
getattr(ec2, args.action + "_instances")(InstanceIds=[instance])
|
|
269
|
+
ec2.get_waiter("instance_running" if args.action == "start" else "instance_stopped").wait(InstanceIds=[instance])
|
|
270
|
+
print(f"Instance {instance}: {'running' if args.action == 'start' else 'stopped'}")
|
|
271
|
+
elif args.action == "connect":
|
|
272
|
+
cmd = ["aws", "--profile", conf["profile"], "--region", conf["region"], "ssm", "start-session", "--target", instance]
|
|
273
|
+
if args.user:
|
|
274
|
+
cmd += ["--document-name", "AWS-StartInteractiveCommand", "--parameters", f"command=sudo -iu {username(args.user)}"]
|
|
275
|
+
subprocess.run(cmd, check=True)
|
|
276
|
+
elif args.action in ("ssh", "ssh-config"):
|
|
277
|
+
user = username(args.user)
|
|
278
|
+
key = args.key_path or Path.home() / ".ssh" / f"boxman-{name}-ed25519"
|
|
279
|
+
keypair(key)
|
|
280
|
+
add_key(session.client("ssm"), instance, user, Path(str(key) + ".pub"))
|
|
281
|
+
tunnel = proxy(conf)
|
|
282
|
+
if args.action == "ssh":
|
|
283
|
+
cmd = ["ssh", "-t", "-o", f"ProxyCommand={tunnel}", "-o", "StrictHostKeyChecking=accept-new", "-i", str(key), f"{user}@{instance}"]
|
|
284
|
+
if args.container:
|
|
285
|
+
cmd.append(f"podman exec -it {shlex.quote(args.container)} bash")
|
|
286
|
+
subprocess.run(cmd, check=True)
|
|
287
|
+
else:
|
|
288
|
+
write_ssh_config(args.alias, f"Host {args.alias}\n HostName {instance}\n User {user}\n IdentityFile {key}\n ProxyCommand {tunnel}")
|
|
289
|
+
print(f"SSH host {args.alias} configured for {user}@{instance}")
|
|
290
|
+
elif args.action == "run":
|
|
291
|
+
execute(session.client("ssm"), instance, args.command, args.user)
|
|
292
|
+
except botocore.exceptions.BotoCoreError as exc:
|
|
293
|
+
raise SystemExit(f"AWS error: {exc}") from exc
|
|
294
|
+
except botocore.exceptions.ClientError as exc:
|
|
295
|
+
raise SystemExit(f"AWS error: {exc.response['Error'].get('Message', exc)}") from exc
|
|
296
|
+
except subprocess.CalledProcessError as exc:
|
|
297
|
+
raise SystemExit(f"Command failed ({exc.returncode}): {exc.cmd}") from exc
|
boxman/system.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install shared Ubuntu packages and prepare users for rootless Podman."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import pwd
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SUBID_START = 100_000
|
|
18
|
+
SUBID_COUNT = 65_536
|
|
19
|
+
USERNAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}")
|
|
20
|
+
|
|
21
|
+
RECIPE = Path(__file__).resolve().parent / "data" / "linux-tools.toml"
|
|
22
|
+
ZIPGET_URL = (
|
|
23
|
+
"https://github.com/vivainio/zipget-rs/releases/latest/download/"
|
|
24
|
+
"zipget-linux-x64-musl"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log(message: str) -> None:
|
|
29
|
+
print(f"[system-setup] {message}", flush=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(*args: str, env: dict[str, str] | None = None) -> None:
|
|
33
|
+
log(f"running: {' '.join(args)}")
|
|
34
|
+
subprocess.run(args, check=True, env=env)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def supports_system_packages(zipget: Path) -> bool:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
(str(zipget), "recipe", "--help"),
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
return result.returncode == 0 and "--system-only" in result.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_zipget(temp: Path) -> Path:
|
|
48
|
+
installed = shutil.which("zipget")
|
|
49
|
+
if installed and supports_system_packages(Path(installed)):
|
|
50
|
+
return Path(installed)
|
|
51
|
+
|
|
52
|
+
zipget = temp / "zipget"
|
|
53
|
+
log(f"downloading {ZIPGET_URL}")
|
|
54
|
+
request = urllib.request.Request(ZIPGET_URL, headers={"User-Agent": "boxman"})
|
|
55
|
+
with urllib.request.urlopen(request, timeout=120) as response: # noqa: S310
|
|
56
|
+
zipget.write_bytes(response.read())
|
|
57
|
+
zipget.chmod(0o755)
|
|
58
|
+
if not supports_system_packages(zipget):
|
|
59
|
+
sys.exit("zipget release does not support recipe --system-only yet")
|
|
60
|
+
return zipget
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def os_release() -> dict[str, str]:
|
|
64
|
+
values: dict[str, str] = {}
|
|
65
|
+
for line in Path("/etc/os-release").read_text().splitlines():
|
|
66
|
+
if "=" in line:
|
|
67
|
+
key, value = line.split("=", 1)
|
|
68
|
+
values[key] = value.strip('"')
|
|
69
|
+
return values
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def allocated_ranges(path: Path) -> list[tuple[int, int]]:
|
|
73
|
+
ranges = []
|
|
74
|
+
for line in path.read_text().splitlines():
|
|
75
|
+
try:
|
|
76
|
+
_, start, count = line.split(":")
|
|
77
|
+
ranges.append((int(start), int(count)))
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue
|
|
80
|
+
return ranges
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def next_subid(path: Path) -> int:
|
|
84
|
+
next_id = max(
|
|
85
|
+
(start + count for start, count in allocated_ranges(path)),
|
|
86
|
+
default=SUBID_START,
|
|
87
|
+
)
|
|
88
|
+
next_id = max(next_id, SUBID_START)
|
|
89
|
+
remainder = (next_id - SUBID_START) % SUBID_COUNT
|
|
90
|
+
return next_id if remainder == 0 else next_id + SUBID_COUNT - remainder
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def has_subid(path: Path, username: str) -> bool:
|
|
94
|
+
return any(
|
|
95
|
+
line.partition(":")[0] == username for line in path.read_text().splitlines()
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ensure_subid(username: str, path: Path, flag: str) -> None:
|
|
100
|
+
if has_subid(path, username):
|
|
101
|
+
return
|
|
102
|
+
start = next_subid(path)
|
|
103
|
+
end = start + SUBID_COUNT - 1
|
|
104
|
+
run("usermod", flag, f"{start}-{end}", username)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
108
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--packages-only",
|
|
111
|
+
action="store_true",
|
|
112
|
+
help="install shared packages without configuring login users",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument("users", nargs="*", help="existing Unix users to configure")
|
|
115
|
+
return parser.parse_args(argv)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def regular_users() -> list[str]:
|
|
119
|
+
"""Return normal login users, excluding nobody and system accounts."""
|
|
120
|
+
return sorted(
|
|
121
|
+
entry.pw_name
|
|
122
|
+
for entry in pwd.getpwall()
|
|
123
|
+
if 1_000 <= entry.pw_uid < 65_534
|
|
124
|
+
and entry.pw_shell not in {"/usr/sbin/nologin", "/bin/false"}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> None:
|
|
129
|
+
args = parse_args(argv)
|
|
130
|
+
if os.geteuid() != 0:
|
|
131
|
+
sys.exit("boxman system must run as root")
|
|
132
|
+
|
|
133
|
+
release = os_release()
|
|
134
|
+
if release.get("ID") != "ubuntu" or release.get("VERSION_ID") != "24.04":
|
|
135
|
+
sys.exit(
|
|
136
|
+
"Ubuntu 24.04 is required "
|
|
137
|
+
f"(found {release.get('ID', 'unknown')} {release.get('VERSION_ID', 'unknown')})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if args.packages_only and args.users:
|
|
141
|
+
sys.exit("--packages-only cannot be combined with usernames")
|
|
142
|
+
|
|
143
|
+
users = [] if args.packages_only else (args.users or regular_users())
|
|
144
|
+
if not args.packages_only and not args.users:
|
|
145
|
+
log(f"auto-detected login users: {', '.join(users) if users else '(none)'}")
|
|
146
|
+
|
|
147
|
+
for username in users:
|
|
148
|
+
if not USERNAME.fullmatch(username):
|
|
149
|
+
sys.exit(f"invalid Unix username: {username!r}")
|
|
150
|
+
try:
|
|
151
|
+
pwd.getpwnam(username)
|
|
152
|
+
except KeyError:
|
|
153
|
+
raise SystemExit(f"user does not exist: {username}") from None
|
|
154
|
+
|
|
155
|
+
if not RECIPE.is_file():
|
|
156
|
+
sys.exit(f"missing zipget recipe: {RECIPE}")
|
|
157
|
+
with tempfile.TemporaryDirectory(prefix="linux-system-") as temp_name:
|
|
158
|
+
zipget = install_zipget(Path(temp_name))
|
|
159
|
+
apt_env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
|
|
160
|
+
run(str(zipget), "recipe", str(RECIPE), "--system-only", env=apt_env)
|
|
161
|
+
run("git", "lfs", "install", "--system")
|
|
162
|
+
|
|
163
|
+
if args.packages_only:
|
|
164
|
+
log("package-only system setup complete")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
for username in users:
|
|
168
|
+
log(f"configuring rootless Podman prerequisites for {username}")
|
|
169
|
+
ensure_subid(username, Path("/etc/subuid"), "--add-subuids")
|
|
170
|
+
ensure_subid(username, Path("/etc/subgid"), "--add-subgids")
|
|
171
|
+
run("loginctl", "enable-linger", username)
|
|
172
|
+
|
|
173
|
+
if not users:
|
|
174
|
+
log("no login users found; user Podman setup skipped")
|
|
175
|
+
log("system setup complete")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
main()
|
boxman/user.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install per-user AI development tools without storing credentials."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tarfile
|
|
13
|
+
import tempfile
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
NODE_MAJOR = 22
|
|
18
|
+
HOME = Path.home()
|
|
19
|
+
LOCAL_BIN = HOME / ".local" / "bin"
|
|
20
|
+
NODE_ROOT = HOME / ".local" / "share" / f"node-v{NODE_MAJOR}"
|
|
21
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
22
|
+
TOOLS_RECIPE = SCRIPT_DIR / "data" / "linux-tools.toml"
|
|
23
|
+
ZIPGET_URL = (
|
|
24
|
+
"https://github.com/vivainio/zipget-rs/releases/latest/download/"
|
|
25
|
+
"zipget-linux-x64-musl"
|
|
26
|
+
)
|
|
27
|
+
CLAUDE_INSTALL_URL = "https://claude.ai/install.sh"
|
|
28
|
+
COPILOT_INSTALL_URL = "https://gh.io/copilot-install"
|
|
29
|
+
UV_INSTALL_URL = "https://astral.sh/uv/install.sh"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def log(message: str) -> None:
|
|
33
|
+
print(f"[user-setup] {message}", flush=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(
|
|
37
|
+
*args: str,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
cwd: Path | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
log(f"running: {' '.join(args)}")
|
|
42
|
+
subprocess.run(args, check=True, env=env, cwd=cwd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def download(url: str, destination: Path) -> None:
|
|
46
|
+
log(f"downloading {url}")
|
|
47
|
+
request = urllib.request.Request(url, headers={"User-Agent": "boxman"})
|
|
48
|
+
with urllib.request.urlopen(request, timeout=120) as response: # noqa: S310
|
|
49
|
+
destination.write_bytes(response.read())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def node_architecture() -> str:
|
|
53
|
+
machine = platform.machine().lower()
|
|
54
|
+
try:
|
|
55
|
+
return {"x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}[machine]
|
|
56
|
+
except KeyError:
|
|
57
|
+
sys.exit(f"unsupported CPU architecture: {machine}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_node_archive(checksums: str, architecture: str) -> tuple[str, str]:
|
|
61
|
+
suffix = f"-linux-{architecture}.tar.xz"
|
|
62
|
+
for line in checksums.splitlines():
|
|
63
|
+
digest, _, filename = line.partition(" ")
|
|
64
|
+
if filename.endswith(suffix):
|
|
65
|
+
return filename, digest
|
|
66
|
+
sys.exit(f"no Node.js {NODE_MAJOR} archive found for {architecture}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install_node(temp: Path) -> None:
|
|
70
|
+
base_url = f"https://nodejs.org/dist/latest-v{NODE_MAJOR}.x"
|
|
71
|
+
checksums_path = temp / "SHASUMS256.txt"
|
|
72
|
+
download(f"{base_url}/SHASUMS256.txt", checksums_path)
|
|
73
|
+
filename, expected_digest = find_node_archive(
|
|
74
|
+
checksums_path.read_text(), node_architecture()
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
archive = temp / filename
|
|
78
|
+
download(f"{base_url}/{filename}", archive)
|
|
79
|
+
actual_digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
|
80
|
+
if actual_digest != expected_digest:
|
|
81
|
+
sys.exit("Node.js checksum verification failed")
|
|
82
|
+
|
|
83
|
+
shutil.rmtree(NODE_ROOT, ignore_errors=True)
|
|
84
|
+
NODE_ROOT.mkdir(parents=True)
|
|
85
|
+
with tarfile.open(archive, "r:xz") as bundle:
|
|
86
|
+
members = bundle.getmembers()
|
|
87
|
+
top_directory = Path(members[0].name).parts[0]
|
|
88
|
+
for member in members:
|
|
89
|
+
member.name = str(Path(member.name).relative_to(top_directory))
|
|
90
|
+
if member.name != ".":
|
|
91
|
+
bundle.extract(member, NODE_ROOT, filter="data")
|
|
92
|
+
|
|
93
|
+
for executable in ("node", "npm", "npx", "corepack"):
|
|
94
|
+
link = LOCAL_BIN / executable
|
|
95
|
+
link.unlink(missing_ok=True)
|
|
96
|
+
link.symlink_to(NODE_ROOT / "bin" / executable)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def install_zipget() -> Path:
|
|
100
|
+
zipget = LOCAL_BIN / "zipget"
|
|
101
|
+
if not zipget.exists():
|
|
102
|
+
download(ZIPGET_URL, zipget)
|
|
103
|
+
zipget.chmod(0o755)
|
|
104
|
+
return zipget
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install_recipe_tools(zipget: Path) -> None:
|
|
108
|
+
if not TOOLS_RECIPE.is_file():
|
|
109
|
+
sys.exit(f"missing zipget recipe: {TOOLS_RECIPE}")
|
|
110
|
+
with tempfile.TemporaryDirectory(prefix="linux-tools-") as stage_name:
|
|
111
|
+
stage = Path(stage_name)
|
|
112
|
+
# The root setup handles apt packages. Keep this step compatible with
|
|
113
|
+
# zipget releases predating [system_packages] support.
|
|
114
|
+
lines = TOOLS_RECIPE.read_text().splitlines(keepends=True)
|
|
115
|
+
user_lines = []
|
|
116
|
+
in_system_packages = False
|
|
117
|
+
for line in lines:
|
|
118
|
+
if line.strip() == "[system_packages]":
|
|
119
|
+
in_system_packages = True
|
|
120
|
+
elif in_system_packages and line.lstrip().startswith("["):
|
|
121
|
+
in_system_packages = False
|
|
122
|
+
if not in_system_packages:
|
|
123
|
+
user_lines.append(line)
|
|
124
|
+
user_recipe = stage / "user-tools.toml"
|
|
125
|
+
user_recipe.write_text("".join(user_lines))
|
|
126
|
+
run(str(zipget), "recipe", str(user_recipe), cwd=stage)
|
|
127
|
+
|
|
128
|
+
aws_installer = stage / "aws-cli-installer" / "aws" / "install"
|
|
129
|
+
if not aws_installer.is_file():
|
|
130
|
+
sys.exit(
|
|
131
|
+
f"zipget did not produce the AWS CLI installer at {aws_installer}"
|
|
132
|
+
)
|
|
133
|
+
args = [
|
|
134
|
+
str(aws_installer),
|
|
135
|
+
"--install-dir",
|
|
136
|
+
str(HOME / ".local" / "aws-cli"),
|
|
137
|
+
"--bin-dir",
|
|
138
|
+
str(LOCAL_BIN),
|
|
139
|
+
]
|
|
140
|
+
if (HOME / ".local" / "aws-cli").exists():
|
|
141
|
+
args.append("--update")
|
|
142
|
+
run(*args)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def install_claude_code(temp: Path) -> None:
|
|
146
|
+
installer = temp / "install-claude-code.sh"
|
|
147
|
+
download(CLAUDE_INSTALL_URL, installer)
|
|
148
|
+
run("bash", str(installer))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def install_copilot(temp: Path) -> None:
|
|
152
|
+
installer = temp / "install-copilot.sh"
|
|
153
|
+
download(COPILOT_INSTALL_URL, installer)
|
|
154
|
+
env = {**os.environ, "PREFIX": str(HOME / ".local")}
|
|
155
|
+
run("bash", str(installer), env=env)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def install_uv(temp: Path) -> None:
|
|
159
|
+
installer = temp / "install-uv.sh"
|
|
160
|
+
download(UV_INSTALL_URL, installer)
|
|
161
|
+
env = {
|
|
162
|
+
**os.environ,
|
|
163
|
+
"UV_INSTALL_DIR": str(LOCAL_BIN),
|
|
164
|
+
"UV_NO_MODIFY_PATH": "1",
|
|
165
|
+
}
|
|
166
|
+
run("sh", str(installer), env=env)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
if os.geteuid() == 0:
|
|
171
|
+
sys.exit("boxman user must run as the target user, not root")
|
|
172
|
+
|
|
173
|
+
LOCAL_BIN.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
NODE_ROOT.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
zipget = install_zipget()
|
|
177
|
+
install_recipe_tools(zipget)
|
|
178
|
+
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="boxman-user-") as temp_name:
|
|
180
|
+
temp = Path(temp_name)
|
|
181
|
+
install_node(temp)
|
|
182
|
+
install_claude_code(temp)
|
|
183
|
+
install_copilot(temp)
|
|
184
|
+
install_uv(temp)
|
|
185
|
+
|
|
186
|
+
log("user setup complete; authenticate claude, copilot, and gh interactively")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
boxman/verify.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify a user's AI development tool installation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
COMMANDS = {
|
|
10
|
+
"git": ("--version",),
|
|
11
|
+
"gocryptfs": ("-version",),
|
|
12
|
+
"gh": ("--version",),
|
|
13
|
+
"podman": ("--version",),
|
|
14
|
+
"node": ("--version",),
|
|
15
|
+
"npm": ("--version",),
|
|
16
|
+
"zipget": ("--version",),
|
|
17
|
+
"aws": ("--version",),
|
|
18
|
+
"herdr": ("--version",),
|
|
19
|
+
"cship": ("--version",),
|
|
20
|
+
"starship": ("--version",),
|
|
21
|
+
"fd": ("--version",),
|
|
22
|
+
"bat": ("--version",),
|
|
23
|
+
"zoxide": ("--version",),
|
|
24
|
+
"delta": ("--version",),
|
|
25
|
+
"uv": ("--version",),
|
|
26
|
+
"claude": ("--version",),
|
|
27
|
+
"copilot": ("version",),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
failures = 0
|
|
33
|
+
for command, args in COMMANDS.items():
|
|
34
|
+
if not shutil.which(command):
|
|
35
|
+
print(f"MISSING {command}")
|
|
36
|
+
failures += 1
|
|
37
|
+
continue
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
(command, *args), capture_output=True, text=True, timeout=20, check=False
|
|
40
|
+
)
|
|
41
|
+
output = (result.stdout or result.stderr).splitlines()
|
|
42
|
+
if result.returncode == 0:
|
|
43
|
+
print(f"OK {command:<10} {output[0] if output else ''}")
|
|
44
|
+
else:
|
|
45
|
+
print(f"FAILED {command:<10} {output[0] if output else ''}")
|
|
46
|
+
failures += 1
|
|
47
|
+
|
|
48
|
+
if shutil.which("podman"):
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
("podman", "info", "--format", "{{.Host.Security.Rootless}}"),
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
timeout=20,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode == 0 and result.stdout.strip() == "true":
|
|
57
|
+
print("OK rootless Podman")
|
|
58
|
+
else:
|
|
59
|
+
print("FAILED Podman is not running rootless")
|
|
60
|
+
failures += 1
|
|
61
|
+
|
|
62
|
+
raise SystemExit(failures)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boxman
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Set up and manage an Ubuntu development box
|
|
5
|
+
Project-URL: Homepage, https://github.com/vivainio/boxman
|
|
6
|
+
Project-URL: Documentation, https://vivainio.github.io/boxman/
|
|
7
|
+
Project-URL: Repository, https://github.com/vivainio/boxman
|
|
8
|
+
Project-URL: Issues, https://github.com/vivainio/boxman/issues
|
|
9
|
+
Keywords: ubuntu,development,ec2,cloudformation,cli
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: ec2
|
|
22
|
+
Requires-Dist: boto3>=1.34; extra == "ec2"
|
|
23
|
+
|
|
24
|
+
# boxman
|
|
25
|
+
|
|
26
|
+
[Read the Boxman book](https://vivainio.github.io/boxman/) for the concepts, setup steps, and command reference.
|
|
27
|
+
|
|
28
|
+
Set up and manage an Ubuntu 24.04 development box. `boxman` is a Python command
|
|
29
|
+
with system, user, and vault operations.
|
|
30
|
+
|
|
31
|
+
## Install a host
|
|
32
|
+
|
|
33
|
+
From this checkout, run the system step as root, then the user step from each
|
|
34
|
+
login account. The first command works with the Ubuntu system Python before
|
|
35
|
+
`uv` is installed:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
sudo python3 -m boxman.cli system
|
|
39
|
+
python3 -m boxman.cli user
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With `uv` available, run it without installing the package:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uvx --from . boxman vault status
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
After publishing the repository, `uvx --from
|
|
49
|
+
git+https://github.com/vivainio/boxman boxman ...` can run the same console
|
|
50
|
+
command. For a persistent command, use `uv tool install .` from this checkout.
|
|
51
|
+
|
|
52
|
+
`boxman system` uses zipget to install the apt packages declared in
|
|
53
|
+
`linux-tools.toml`, sets up Git LFS, and configures rootless Podman for normal
|
|
54
|
+
login users. It accepts explicit usernames, or `--packages-only` for a
|
|
55
|
+
container build. It downloads zipget if no version supporting
|
|
56
|
+
`recipe --system-only` is on root's PATH. A fresh install needs a released
|
|
57
|
+
zipget with that option.
|
|
58
|
+
|
|
59
|
+
`boxman user` installs the tools in the recipe, Node.js 22, Claude Code,
|
|
60
|
+
Copilot CLI, and uv. Run the user step for each account.
|
|
61
|
+
Run `boxman verify` afterward to check the installed commands and rootless
|
|
62
|
+
Podman.
|
|
63
|
+
|
|
64
|
+
The same package set can be used in a dev container:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
podman build -f container/Dockerfile -t boxman-dev .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The container build skips host login-user and systemd configuration. FUSE
|
|
71
|
+
mounting may require additional container privileges, so the vault commands
|
|
72
|
+
are intended for the native host.
|
|
73
|
+
|
|
74
|
+
## Private directory
|
|
75
|
+
|
|
76
|
+
The recipe installs `gocryptfs` and FUSE 3. Each user initializes their own
|
|
77
|
+
vault once and unlocks it after a reboot or unmount:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uvx --from . boxman vault init
|
|
81
|
+
uvx --from . boxman vault unlock
|
|
82
|
+
uvx --from . boxman vault status
|
|
83
|
+
uvx --from . boxman claude # starts Claude with its config in the mounted vault
|
|
84
|
+
uvx --from . boxman vault lock # after stopping processes that use the mount
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Encrypted files live in `~/.private.cipher`; the plaintext mount is
|
|
88
|
+
`~/private`. Initialization and unlocking prompt for the password. Keep the
|
|
89
|
+
password and gocryptfs recovery key outside the host. Back up the encrypted
|
|
90
|
+
directory, including `gocryptfs.conf`, while keeping the plaintext mount out
|
|
91
|
+
of backups.
|
|
92
|
+
|
|
93
|
+
`boxman claude` refuses to run unless the vault is mounted. Use it before the
|
|
94
|
+
first Claude login. It sets `CLAUDE_CONFIG_DIR` to `~/private/claude` and does
|
|
95
|
+
not move existing credentials from `~/.claude`. Configure other tools'
|
|
96
|
+
credential locations separately if they should use the vault. Persistent
|
|
97
|
+
agents need the vault mounted while they use credentials. Locking fails while
|
|
98
|
+
a process holds files in the mount open.
|
|
99
|
+
|
|
100
|
+
The vault protects its backing files and snapshots while locked. It does not
|
|
101
|
+
hide credentials from the user's running processes or a host administrator
|
|
102
|
+
while unlocked. Keep home directory permissions private to each Unix owner.
|
|
103
|
+
|
|
104
|
+
## EC2 host
|
|
105
|
+
|
|
106
|
+
Install the optional AWS dependency, then create
|
|
107
|
+
`${XDG_CONFIG_HOME:-~/.config}/boxman/ec2.toml`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv tool install '.[ec2]'
|
|
111
|
+
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/boxman"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```toml
|
|
115
|
+
[ec2]
|
|
116
|
+
profile = "your-aws-profile"
|
|
117
|
+
region = "your-region"
|
|
118
|
+
stack_name = "your-stack-name"
|
|
119
|
+
|
|
120
|
+
[ec2.tags]
|
|
121
|
+
Owner = "your-owner"
|
|
122
|
+
Environment = "your-environment"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The config contains no credentials; AWS uses the named profile. Supply the tags
|
|
126
|
+
required by your account. The TOML file selects the AWS profile, region, stack,
|
|
127
|
+
and tags. Command-line options override the file;
|
|
128
|
+
`--tag KEY=VALUE` adds or overrides a tag. `--config PATH` selects another TOML
|
|
129
|
+
file. Put global options before the action:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
boxman ec2 init --vpc-id vpc-... --subnet-id subnet-... \
|
|
133
|
+
--instance-type t3.xlarge --volume-size-gb 100 \
|
|
134
|
+
--instance-name mybox \
|
|
135
|
+
--ami-id /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id
|
|
136
|
+
boxman ec2 deploy
|
|
137
|
+
boxman ec2 status
|
|
138
|
+
boxman ec2 start
|
|
139
|
+
boxman ec2 stop
|
|
140
|
+
boxman ec2 connect -u myuser
|
|
141
|
+
boxman ec2 ssh -u myuser
|
|
142
|
+
boxman ec2 ssh-config -u myuser --alias mybox
|
|
143
|
+
boxman ec2 run -u myuser 'uname -a'
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`init` writes `${XDG_CONFIG_HOME:-~/.config}/boxman/stacks/<stack_name>.yaml`
|
|
147
|
+
with the instance settings as parameter defaults and refuses to overwrite an
|
|
148
|
+
existing file. Edit that YAML to customize the stack. `deploy` reads the YAML
|
|
149
|
+
for the configured stack name and sends its defaults as CloudFormation
|
|
150
|
+
parameters. Tags from `[ec2.tags]` and repeatable
|
|
151
|
+
`--tag KEY=VALUE` options become CloudFormation stack tags. A single config
|
|
152
|
+
file selects one stack; use `--config PATH` for another box.
|
|
153
|
+
|
|
154
|
+
Deployment creates or updates a CloudFormation stack containing an Ubuntu EC2
|
|
155
|
+
instance, an SSM role, and an EC2 Instance Connect Endpoint. SSH uses that
|
|
156
|
+
endpoint and installs a generated public key through SSM. `ssh-config` writes a
|
|
157
|
+
marked host entry to `~/.ssh/config`. The stack name selects the instance for
|
|
158
|
+
all subsequent commands. Starting, stopping, and deploying incur AWS charges.
|
|
159
|
+
|
|
160
|
+
## Release
|
|
161
|
+
|
|
162
|
+
Build the Zensical book locally with `zensical build --clean`. The docs workflow
|
|
163
|
+
publishes it to GitHub Pages when documentation changes on `main`.
|
|
164
|
+
|
|
165
|
+
A published GitHub release triggers the PyPI workflow. Use a `vX.Y.Z` tag;
|
|
166
|
+
the workflow sets the package version from the release tag, builds the wheel
|
|
167
|
+
and source distribution, and publishes with PyPI trusted publishing. Configure
|
|
168
|
+
a PyPI trusted publisher for repository `vivainio/boxman`, workflow
|
|
169
|
+
`publish.yml`, environment `pypi` before the first release.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
boxman/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
boxman/cli.py,sha256=EZ35JcoM9o-FgCC1HFb594l8d9x0xSvKUm_YcE5XecA,4012
|
|
3
|
+
boxman/ec2.py,sha256=Lsr17vnQvYNyzxb-yi6yb7VkqgUB5yn3xmmx8gjxFKY,14119
|
|
4
|
+
boxman/system.py,sha256=LnD66M_3j3eJ4ThzUt1e149qOzN0u18EiDf-EbHIXls,5655
|
|
5
|
+
boxman/user.py,sha256=HFnDuk3G-B1jEp3f3qmp5OM1e4Ka5aROZUuULNEgDLE,6179
|
|
6
|
+
boxman/verify.py,sha256=TUDM90uAoDkVQhPVqmMDG3Mnh2aARPjC0Df36jGgJ5Q,1848
|
|
7
|
+
boxman/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
boxman/data/ec2-template.yaml,sha256=1khSQuedLGlYKhYhcRkIMa6ms-43_RGjMw6SKCRnSmM,3753
|
|
9
|
+
boxman/data/linux-tools.toml,sha256=dX43xzB7tT0_nSj0C5X8IGwxdW3bZ9n_-LfCbdRH57w,2511
|
|
10
|
+
boxman-0.1.0.dist-info/METADATA,sha256=CXozDBpVsDCOEYKS_NDMgXm9INOW7ryqm_YLwBTG6wc,6608
|
|
11
|
+
boxman-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
boxman-0.1.0.dist-info/entry_points.txt,sha256=NCnQD5BTI7cuNJaO7FM1NIR6xownHScvOvlLRWKwV0M,43
|
|
13
|
+
boxman-0.1.0.dist-info/top_level.txt,sha256=l75VydLS2iSnvvs6j0TWg-QekP0OIhkTk-WIvKgQknI,7
|
|
14
|
+
boxman-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
boxman
|