kento-core 1.6.0.dev1__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.
kento/cloudinit.py ADDED
@@ -0,0 +1,201 @@
1
+ """Cloud-init NoCloud seed generation for kento containers."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def detect_cloudinit(layers: str) -> bool:
9
+ """Check if any layer in the colon-separated layer paths has cloud-init.
10
+
11
+ Layer paths are podman's overlay diff directories — each layer only
12
+ holds files that changed at that layer. cloud-init might be installed
13
+ in any layer, so we scan all of them for a broad set of markers.
14
+ """
15
+ markers = [
16
+ "usr/bin/cloud-init",
17
+ "usr/sbin/cloud-init",
18
+ "etc/cloud/cloud.cfg",
19
+ "lib/systemd/system/cloud-init.service",
20
+ "usr/lib/systemd/system/cloud-init.service",
21
+ ]
22
+ for layer_path in layers.split(":"):
23
+ layer = Path(layer_path)
24
+ for marker in markers:
25
+ if (layer / marker).exists():
26
+ return True
27
+ return False
28
+
29
+
30
+ def generate_meta_data(name: str, instance_id: str) -> str:
31
+ """Generate NoCloud meta-data content."""
32
+ return f"instance-id: {instance_id}\nlocal-hostname: {name}\n"
33
+
34
+
35
+ def generate_user_data(*, timezone: str | None = None,
36
+ ssh_keys: str | None = None,
37
+ ssh_key_user: str = "root",
38
+ ssh_host_key_dir: Path | None = None,
39
+ env: list[str] | None = None) -> str:
40
+ """Generate NoCloud user-data content.
41
+
42
+ ssh_keys is the concatenated authorized_keys content.
43
+ ssh_host_key_dir is the path to the container's ssh-host-keys/ dir.
44
+ """
45
+ lines = ["#cloud-config"]
46
+
47
+ if timezone:
48
+ lines.append(f"timezone: {timezone}")
49
+
50
+ # SSH authorized keys
51
+ if ssh_keys:
52
+ keys = [k.strip() for k in ssh_keys.strip().splitlines()
53
+ if k.strip() and not k.strip().startswith("#")]
54
+ if keys:
55
+ if ssh_key_user == "root":
56
+ lines.append("ssh_authorized_keys:")
57
+ for key in keys:
58
+ lines.append(f" - {key}")
59
+ else:
60
+ lines.append("users:")
61
+ lines.append(f" - name: {ssh_key_user}")
62
+ lines.append(" ssh_authorized_keys:")
63
+ for key in keys:
64
+ lines.append(f" - {key}")
65
+
66
+ # SSH host keys
67
+ if ssh_host_key_dir and ssh_host_key_dir.is_dir():
68
+ host_key_lines = _generate_ssh_keys_section(ssh_host_key_dir)
69
+ if host_key_lines:
70
+ lines.append("ssh_keys:")
71
+ lines.extend(host_key_lines)
72
+
73
+ # Environment variables via write_files
74
+ if env:
75
+ lines.append("write_files:")
76
+ lines.append(" - path: /etc/environment")
77
+ lines.append(" content: |")
78
+ for e in env:
79
+ lines.append(f" {e}")
80
+
81
+ lines.append("")
82
+ return "\n".join(lines)
83
+
84
+
85
+ def _generate_ssh_keys_section(key_dir: Path) -> list[str]:
86
+ """Generate the ssh_keys cloud-config section from host key files."""
87
+ lines = []
88
+ for key_type in ("rsa", "ecdsa", "ed25519"):
89
+ priv_file = key_dir / f"ssh_host_{key_type}_key"
90
+ pub_file = key_dir / f"ssh_host_{key_type}_key.pub"
91
+ if priv_file.is_file():
92
+ priv_content = priv_file.read_text()
93
+ lines.append(f" {key_type}_private: |")
94
+ for line in priv_content.splitlines():
95
+ lines.append(f" {line}")
96
+ if pub_file.is_file():
97
+ pub_content = pub_file.read_text().strip()
98
+ lines.append(f" {key_type}_public: {pub_content}")
99
+ return lines
100
+
101
+
102
+ def generate_network_config(*, ip: str | None = None,
103
+ gateway: str | None = None,
104
+ dns: str | None = None,
105
+ searchdomain: str | None = None) -> str | None:
106
+ """Generate NoCloud network-config (v2 format). Returns None if no network config needed."""
107
+ if not ip and not dns and not searchdomain:
108
+ return None
109
+
110
+ lines = ["network:", " version: 2", " ethernets:", " all:",
111
+ " match:", ' name: "*"']
112
+
113
+ if ip:
114
+ lines.append(" addresses:")
115
+ lines.append(f" - {ip}")
116
+ if gateway:
117
+ lines.append(" routes:")
118
+ lines.append(" - to: default")
119
+ lines.append(f" via: {gateway}")
120
+ else:
121
+ lines.append(" dhcp4: true")
122
+
123
+ if dns or searchdomain:
124
+ lines.append(" nameservers:")
125
+ if dns:
126
+ lines.append(" addresses:")
127
+ lines.append(f" - {dns}")
128
+ if searchdomain:
129
+ lines.append(" search:")
130
+ lines.append(f" - {searchdomain}")
131
+
132
+ lines.append("")
133
+ return "\n".join(lines)
134
+
135
+
136
+ def compute_instance_id(name: str, *, ip: str | None = None,
137
+ gateway: str | None = None,
138
+ dns: str | None = None,
139
+ searchdomain: str | None = None,
140
+ timezone: str | None = None,
141
+ env: list[str] | None = None,
142
+ ssh_key_user: str = "root",
143
+ has_ssh_keys: bool = False,
144
+ has_ssh_host_keys: bool = False) -> str:
145
+ """Compute a content-hash instance-id from configuration.
146
+
147
+ When config changes, the hash changes, causing cloud-init to re-run.
148
+ """
149
+ config = {
150
+ "name": name,
151
+ "ip": ip,
152
+ "gateway": gateway,
153
+ "dns": dns,
154
+ "searchdomain": searchdomain,
155
+ "timezone": timezone,
156
+ "env": sorted(env) if env else None,
157
+ "ssh_key_user": ssh_key_user,
158
+ "has_ssh_keys": has_ssh_keys,
159
+ "has_ssh_host_keys": has_ssh_host_keys,
160
+ }
161
+ canonical = json.dumps(config, sort_keys=True, separators=(",", ":"))
162
+ h = hashlib.sha256(canonical.encode()).hexdigest()[:16]
163
+ return f"kento-{h}"
164
+
165
+
166
+ def write_seed(container_dir: Path, *, name: str,
167
+ ip: str | None = None, gateway: str | None = None,
168
+ dns: str | None = None, searchdomain: str | None = None,
169
+ timezone: str | None = None, env: list[str] | None = None,
170
+ ssh_keys: str | None = None, ssh_key_user: str = "root",
171
+ ssh_host_key_dir: Path | None = None) -> None:
172
+ """Generate and write NoCloud seed files to container_dir/cloud-seed/."""
173
+ seed_dir = container_dir / "cloud-seed"
174
+ seed_dir.mkdir(parents=True, exist_ok=True)
175
+
176
+ # Compute instance-id
177
+ iid = compute_instance_id(
178
+ name, ip=ip, gateway=gateway, dns=dns, searchdomain=searchdomain,
179
+ timezone=timezone, env=env, ssh_key_user=ssh_key_user,
180
+ has_ssh_keys=bool(ssh_keys),
181
+ has_ssh_host_keys=bool(ssh_host_key_dir and ssh_host_key_dir.is_dir()),
182
+ )
183
+
184
+ # meta-data
185
+ (seed_dir / "meta-data").write_text(generate_meta_data(name, iid))
186
+
187
+ # user-data
188
+ user_data = generate_user_data(
189
+ timezone=timezone, ssh_keys=ssh_keys, ssh_key_user=ssh_key_user,
190
+ ssh_host_key_dir=ssh_host_key_dir, env=env,
191
+ )
192
+ (seed_dir / "user-data").write_text(user_data)
193
+
194
+ # network-config (optional)
195
+ net_config = generate_network_config(ip=ip, gateway=gateway, dns=dns,
196
+ searchdomain=searchdomain)
197
+ if net_config:
198
+ (seed_dir / "network-config").write_text(net_config)
199
+ else:
200
+ # Remove stale network-config if it existed from a prior scrub
201
+ (seed_dir / "network-config").unlink(missing_ok=True)