offspot-config 1.3.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.
File without changes
@@ -0,0 +1,416 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from offspot_config.constants import CONTENT_TARGET_PATH
6
+ from offspot_config.inputs import BaseConfig, BlockStr, FileConfig
7
+ from offspot_config.oci_images import OCIImage
8
+ from offspot_config.packages import AppPackage, FilesPackage, ZimPackage
9
+ from offspot_config.utils.yaml import yaml_dump
10
+
11
+
12
+ class ConfigBuilder:
13
+ def __init__(
14
+ self,
15
+ *,
16
+ base: BaseConfig,
17
+ name: str = "My Offspot",
18
+ tld: str | None = "offspot",
19
+ domain: str | None = "my-offspot",
20
+ ssid: str | None = "my-offspot",
21
+ passphrase: str | None = None,
22
+ timezone: str | None = "UTC", # noqa: ARG002
23
+ as_gateway: bool | None = False,
24
+ write_config: bool | None = False,
25
+ ):
26
+ self.name = name
27
+ self.config: dict[str, Any] = {
28
+ "base": {"source": base.source, "rootfs_size": base.rootfs_size},
29
+ "output": {"size": "auto"},
30
+ "oci_images": set(),
31
+ "files": [],
32
+ "write_config": write_config,
33
+ "offspot": {
34
+ "containers": {"name": "offspot", "services": {}},
35
+ "ap": {
36
+ "domain": domain,
37
+ "tld": tld,
38
+ "ssid": ssid,
39
+ "passphrase": passphrase,
40
+ "as-gateway": as_gateway,
41
+ },
42
+ "ethernet": {"type": "dhcp"},
43
+ "timezone": "UTC",
44
+ },
45
+ }
46
+
47
+ # whether dashboard will offer downloads for ZIM files
48
+ self.dashboard_offers_zim_downloads = True
49
+ # every card the dashboard will display
50
+ self.dashboard_entries = []
51
+ # all files that will be downloaded and places onto disk
52
+ self.file_entries = []
53
+ # domain of services that must be reversed to (all but special cases)
54
+ self.reversed_services = set()
55
+ # domain: folder map of virtual services serving only files
56
+ self.files_mapping = {}
57
+
58
+ self.with_kiwixserve: bool = False
59
+ self.with_files: bool = False
60
+ self.with_reverseproxy: bool = False
61
+ self.with_dashboard: bool = False
62
+ self.with_captive_portal: bool = False
63
+ self.with_hwclock: bool = False
64
+
65
+ @property
66
+ def compose(self):
67
+ return self.config["offspot"]["containers"]
68
+
69
+ @property
70
+ def fqdn(self):
71
+ return (
72
+ f"{self.config['offspot']['ap']['domain']}."
73
+ f"{self.config['offspot']['ap']['tld']}"
74
+ )
75
+
76
+ def update_offspot_config(self, **kwargs):
77
+ self.config["offspot"].update(kwargs)
78
+
79
+ # def add_package(self, ident: str):
80
+ # try:
81
+ # prefix, ident = ident.split(":", 1)
82
+ # except Exception as exc:
83
+ # raise ValueError(
84
+ # f"Invalid prefix:ident value for package: {ident}"
85
+ # ) from exc
86
+
87
+ # if prefix == "zim":
88
+ # return self.add_zim(ident)
89
+ # if prefix == "app":
90
+ # return self.add_app(ident)
91
+ # if prefix == "file":
92
+ # return self.add_files_package(ident)
93
+
94
+ # raise ValueError(f"Invalid package prefix: {prefix}")
95
+
96
+ def set_output_size(self, size: int):
97
+ self.config["output"]["size"] = size
98
+
99
+ def add_dashboard(self, *, allow_zim_downloads: bool | None = False):
100
+ self.dashboard_offers_zim_downloads = allow_zim_downloads
101
+ if self.with_dashboard:
102
+ return
103
+
104
+ self.with_dashboard = True
105
+
106
+ image = OCIImage(
107
+ ident="ghcr.io/offspot/dashboard:1.0",
108
+ filesize=119941120,
109
+ fullsize=119838811,
110
+ )
111
+ self.config["oci_images"].add(image)
112
+
113
+ # add to compose
114
+ self.compose["services"]["home"] = {
115
+ "image": image.source,
116
+ "container_name": "home",
117
+ "pull_policy": "never",
118
+ "restart": "unless-stopped",
119
+ "expose": ["80"],
120
+ "volumes": [
121
+ {
122
+ "type": "bind",
123
+ "source": str(CONTENT_TARGET_PATH / "dashboard.yaml"),
124
+ "target": "/src/home.yaml",
125
+ "read_only": True,
126
+ }
127
+ ],
128
+ }
129
+
130
+ def gen_dashboard_config(self):
131
+ """Generate and add YAML config file for dashboard, based on entries"""
132
+
133
+ if self.dashboard_offers_zim_downloads:
134
+ download_fqdn = f"download-zims.{self.fqdn}"
135
+ else:
136
+ download_fqdn = None
137
+
138
+ payload = {
139
+ "metadata": {"name": self.name, "fqdn": self.fqdn},
140
+ "packages": [
141
+ package.to_dashboard_entry(fqdn=self.fqdn, download_fqdn=download_fqdn)
142
+ for package in self.dashboard_entries
143
+ ],
144
+ }
145
+
146
+ self.add_file(
147
+ url_or_content=BlockStr(yaml_dump(payload)),
148
+ to=str(CONTENT_TARGET_PATH / "dashboard.yaml"),
149
+ size=0,
150
+ via="direct",
151
+ is_url=False,
152
+ )
153
+
154
+ def add_reverseproxy(self):
155
+ if self.with_reverseproxy:
156
+ return
157
+
158
+ self.with_reverseproxy = True
159
+
160
+ image = OCIImage(
161
+ ident="ghcr.io/offspot/reverse-proxy:1.1",
162
+ filesize=114974720,
163
+ fullsize=114894424,
164
+ )
165
+ self.config["oci_images"].add(image)
166
+
167
+ # add to compose
168
+ self.compose["services"]["reverse-proxy"] = {
169
+ "image": image.source,
170
+ "container_name": "reverse-proxy",
171
+ "environment": {
172
+ "FQDN": self.fqdn,
173
+ },
174
+ "pull_policy": "never",
175
+ "restart": "unless-stopped",
176
+ "ports": ["80:80", "443:443"],
177
+ }
178
+
179
+ def add_captive_portal(self):
180
+ if self.with_captive_portal:
181
+ return
182
+
183
+ self.with_captive_portal = True
184
+
185
+ image = OCIImage(
186
+ ident="ghcr.io/offspot/captive-portal:1.0",
187
+ filesize=187668480,
188
+ fullsize=187604243,
189
+ )
190
+ self.config["oci_images"].add(image)
191
+
192
+ # add to compose
193
+ self.compose["services"]["home-portal"] = {
194
+ "image": image.source,
195
+ "container_name": "home-portal",
196
+ "network_mode": "host",
197
+ "cap_add": [
198
+ "NET_ADMIN",
199
+ ],
200
+ "environment": {
201
+ "HOTSPOT_NAME": self.name,
202
+ "HOTSPOT_IP": "192.168.2.1",
203
+ "HOTSPOT_FQDN": self.fqdn,
204
+ "CAPTURED_NETWORKS": "192.168.2.128/25",
205
+ "TIMEOUT": "60",
206
+ "FILTER_MODULE": "portal_filter",
207
+ },
208
+ "pull_policy": "never",
209
+ "restart": "unless-stopped",
210
+ "expose": ["2080", "2443"],
211
+ "volumes": [
212
+ {
213
+ "type": "bind",
214
+ "source": "/var/run/internet",
215
+ "target": "/var/run/internet",
216
+ "read_only": True,
217
+ }
218
+ ],
219
+ }
220
+
221
+ def add_hwclock(self):
222
+ if self.with_hwclock:
223
+ return
224
+
225
+ self.with_hwclock = True
226
+
227
+ # add image
228
+ image = OCIImage(
229
+ ident="ghcr.io/offspot/hwclock:1.0",
230
+ filesize=59412480,
231
+ fullsize=59382985,
232
+ )
233
+ self.config["oci_images"].add(image)
234
+
235
+ # add to compose
236
+ self.compose["services"]["hwclock"] = {
237
+ "image": image.source,
238
+ "container_name": "hwclock",
239
+ "pull_policy": "never",
240
+ "read_only": True,
241
+ "restart": "unless-stopped",
242
+ "expose": ["80"],
243
+ "cap_add": ["CAP_SYS_TIME"],
244
+ "privileged": True,
245
+ }
246
+
247
+ self.reversed_services.add("hwclock")
248
+
249
+ def add_zim(self, zim: ZimPackage):
250
+ if zim not in self.dashboard_entries:
251
+ self.dashboard_entries.append(zim)
252
+
253
+ self.add_file(
254
+ url_or_content=zim.download_url,
255
+ to=str(CONTENT_TARGET_PATH / "zims" / zim.filename),
256
+ via="direct",
257
+ size=zim.download_size,
258
+ is_url=True,
259
+ )
260
+
261
+ if self.with_kiwixserve:
262
+ return
263
+
264
+ self.with_kiwixserve = True
265
+
266
+ image = OCIImage(
267
+ ident="ghcr.io/offspot/kiwix-serve:3.5.0-2",
268
+ filesize=29194240,
269
+ fullsize=29162475,
270
+ )
271
+ self.config["oci_images"].add(image)
272
+
273
+ # add to compose
274
+ self.compose["services"]["kiwix"] = {
275
+ "image": image.source,
276
+ "container_name": "kiwix",
277
+ "pull_policy": "never",
278
+ "restart": "unless-stopped",
279
+ "expose": ["80"],
280
+ "volumes": [
281
+ {
282
+ "type": "bind",
283
+ "source": f"{CONTENT_TARGET_PATH}/zims",
284
+ "target": "/data",
285
+ "read_only": True,
286
+ }
287
+ ],
288
+ "command": '/bin/sh -c "kiwix-serve --blockexternal '
289
+ '--port 80 --nodatealiases /data/*.zim"',
290
+ }
291
+
292
+ if self.dashboard_offers_zim_downloads:
293
+ self.add_files_service()
294
+ self.files_mapping.update({"zim-downloads": "zims"})
295
+
296
+ self.reversed_services.add("kiwix")
297
+
298
+ def add_app(self, package: AppPackage):
299
+ if package.kind != "app":
300
+ raise ValueError(f"Package {package.ident} is not an app")
301
+
302
+ if package.ident in self.compose["services"]:
303
+ return
304
+
305
+ # add file if app includes one
306
+ if package.has_file():
307
+ self.config["files"].append(package.as_fileconfig())
308
+
309
+ self.config["oci_images"].add(package.oci_image)
310
+ self.compose["services"][package.ident] = {
311
+ "image": package.oci_image.source,
312
+ "container_name": package.domain,
313
+ "pull_policy": "never",
314
+ "restart": "unless-stopped",
315
+ "expose": ["80"],
316
+ }
317
+
318
+ self.reversed_services.add(package.domain)
319
+
320
+ def add_files_package(self, package: FilesPackage):
321
+ # add package to dashboard for a link
322
+ if package in self.dashboard_entries:
323
+ return
324
+ self.dashboard_entries.append(package)
325
+
326
+ # add to files so it gets downloaded
327
+ if package.as_fileconfig() not in self.config["files"]:
328
+ self.config["files"].append(package.as_fileconfig())
329
+
330
+ self.add_files_service()
331
+
332
+ # add to package_entries to it gets its endpoint in reverse proxy
333
+ if package not in self.file_entries:
334
+ self.file_entries.append(package)
335
+
336
+ self.files_mapping.update({package.domain: package.ident})
337
+
338
+ self.reversed_services.add("files")
339
+
340
+ def add_files_service(self):
341
+ # add image to compose
342
+ if not self.with_files:
343
+ image = OCIImage(
344
+ ident="ghcr.io/offspot/file-browser:1.0",
345
+ filesize=47226880,
346
+ fullsize=47162907,
347
+ )
348
+
349
+ # add to compose
350
+ self.config["oci_images"].add(image)
351
+ self.compose["services"]["files"] = {
352
+ "image": image.source,
353
+ "container_name": "files",
354
+ "pull_policy": "never",
355
+ "restart": "unless-stopped",
356
+ "expose": ["80"],
357
+ "volumes": [
358
+ {
359
+ "type": "bind",
360
+ "source": f"{CONTENT_TARGET_PATH}/files",
361
+ "target": "/data",
362
+ "read_only": True,
363
+ }
364
+ ],
365
+ }
366
+
367
+ self.with_files = True
368
+
369
+ def add_file(
370
+ self,
371
+ *,
372
+ url_or_content: str,
373
+ to: str,
374
+ via: str,
375
+ size: int,
376
+ is_url: bool | None = True,
377
+ ):
378
+ self.config["files"].append(
379
+ FileConfig(
380
+ **{
381
+ "url" if is_url else "content": url_or_content,
382
+ "to": to,
383
+ "via": via,
384
+ "size": size,
385
+ }
386
+ )
387
+ )
388
+
389
+ def render(self) -> str:
390
+ """compute config based on requests"""
391
+ ...
392
+
393
+ # add kiwix apps?
394
+
395
+ # gen dashboard.yaml
396
+ if self.with_dashboard:
397
+ self.gen_dashboard_config()
398
+
399
+ # update reverseproxy config
400
+ # > domain to subfolder for all files packages + zim-dl
401
+ self.compose["services"]["reverse-proxy"]["environment"].update(
402
+ {
403
+ "SERVICES": ",".join(self.reversed_services),
404
+ "FILES_MAPPING": ",".join(
405
+ f"{domain}:{folder}"
406
+ for domain, folder in self.files_mapping.items()
407
+ ),
408
+ }
409
+ )
410
+
411
+ # render compose
412
+
413
+ # compute output size
414
+ # self.config["output"] = get_size_for(self.config)
415
+
416
+ return yaml_dump(self.config)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import cast
5
+
6
+ from offspot_config.packages import AppPackage, FilesPackage, Package
7
+
8
+
9
+ class Catalog(dict):
10
+ def __init__(self, content: str | None = None):
11
+ super().__init__()
12
+ if content:
13
+ self.update_from(content)
14
+
15
+ def update_from(self, content: str):
16
+ for entry in json.loads(content):
17
+ cls = {"app": AppPackage, "files": FilesPackage}[entry["kind"]]
18
+ self[entry["ident"]] = cls(**entry)
19
+
20
+ def get_package(self, ident: str) -> Package:
21
+ if self[ident].kind == "app":
22
+ return cast(AppPackage, self[ident])
23
+ elif self[ident].kind == "files":
24
+ return cast(FilesPackage, self[ident])
25
+ return self[ident]
26
+
27
+ def get_apppackage(self, ident: str) -> AppPackage:
28
+ package = self.get_package(ident)
29
+ if not isinstance(package, AppPackage):
30
+ raise KeyError("No app matching {ident}")
31
+ return package
32
+
33
+ def get_filespackage(self, ident: str) -> FilesPackage:
34
+ package = self.get_package(ident)
35
+ if not isinstance(package, FilesPackage):
36
+ raise KeyError("No files matching {ident}")
37
+ return package
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ import shutil
5
+
6
+ DATA_PART_PATH: pathlib.Path = pathlib.Path("/data")
7
+ CONTENT_TARGET_PATH: pathlib.Path = DATA_PART_PATH / "contents"
8
+ SUPPORTED_UNPACKING_FORMATS: list[str] = [f[0] for f in shutil.get_unpack_formats()]
9
+ POST_EXPANSION_UNWANTED_PATTERNS = (
10
+ ### Linux ###
11
+ "*~",
12
+ # temp files which can be created if process still has handle open of a deleted file
13
+ ".fuse_hidden*",
14
+ # KDE directory preferences
15
+ ".directory",
16
+ # Linux trash folder which might appear on any partition or disk
17
+ ".Trash-*",
18
+ # .nfs files are created when an open file is removed but is still being accessed
19
+ ".nfs*",
20
+ ### macOS ###
21
+ ".DS_Store",
22
+ ".AppleDouble",
23
+ ".LSOverride",
24
+ # Thumbnails
25
+ "._*",
26
+ # Files that might appear in the root of a volume
27
+ ".DocumentRevisions-V100",
28
+ ".fseventsd",
29
+ ".Spotlight-V100",
30
+ ".TemporaryItems",
31
+ ".Trashes",
32
+ ".VolumeIcon.icns",
33
+ ".com.apple.timemachine.donotpresent",
34
+ # Directories potentially created on remote AFP share
35
+ ".AppleDB",
36
+ ".AppleDesktop",
37
+ "Network Trash Folder",
38
+ ".apdisk",
39
+ # iCloud generated files
40
+ "*.icloud",
41
+ ### Windows ###
42
+ # Windows thumbnail cache files
43
+ "Thumbs.db",
44
+ "Thumbs.db:encryptable",
45
+ "ehthumbs.db",
46
+ "ehthumbs_vista.db",
47
+ # Dump file
48
+ "*.stackdump",
49
+ # Folder config file
50
+ "desktop.ini",
51
+ "Desktop.ini",
52
+ # Recycle Bin used on file shares
53
+ "$RECYCLE.BIN",
54
+ # Windows shortcuts
55
+ "*.lnk",
56
+ )
offspot_config/file.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ import urllib.parse
5
+
6
+ from offspot_config.constants import DATA_PART_PATH, SUPPORTED_UNPACKING_FORMATS
7
+ from offspot_config.utils.download import get_online_rsc_size
8
+ from offspot_config.utils.misc import get_filesize
9
+
10
+
11
+ class File:
12
+ """In-Config reference to a file to write to the data partition
13
+
14
+ Created from files entries in config:
15
+ - to: str
16
+ mandatory destination to save file into. Must be inside /data
17
+ - size: optional[int]
18
+ size of (expanded) content. If specified, must be >= source file
19
+ - via: optional[str]
20
+ method to process source file (not for content). Values in File.unpack_formats
21
+ - url: optional[str]
22
+ URL to download file from
23
+ - content: optional[str]
24
+ plain text content to write to destination
25
+
26
+ one of content or url must be supplied. content has priority"""
27
+
28
+ kind: str = "file" # Item interface
29
+
30
+ unpack_formats: list[str]
31
+
32
+ def __init__(self, payload: dict[str, str | int]):
33
+ self.unpack_formats = ["direct", *SUPPORTED_UNPACKING_FORMATS]
34
+ self.url: urllib.parse.ParseResult | None = None
35
+ self.content: str = str(payload.get("content", "") or "").strip()
36
+
37
+ if not self.content:
38
+ try:
39
+ self.url = urllib.parse.urlparse(str(payload["url"]))
40
+ except Exception as exc:
41
+ raise ValueError(f"URL “{payload.get('url')}” is incorrect") from exc
42
+
43
+ self.to: pathlib.Path = pathlib.Path(str(payload["to"])).resolve()
44
+ if not self.to.is_relative_to(DATA_PART_PATH):
45
+ raise ValueError(f"{self.to} not a descendent of {DATA_PART_PATH}")
46
+
47
+ self.via: str = str(payload.get("via", "direct"))
48
+ if self.via not in self.unpack_formats:
49
+ raise NotImplementedError(f"Unsupported handler `{self.via}`")
50
+
51
+ # initialized has unknown
52
+ self._size: int = int(payload.get("size", -1))
53
+ self._fullsize: int | None = (
54
+ int(payload["fullsize"]) if "fullsize" in payload else None
55
+ )
56
+
57
+ @property
58
+ def source(self) -> str: # Item interface
59
+ return self.geturl()
60
+
61
+ @property
62
+ def size(self): # Item interface
63
+ if self._size < 0:
64
+ return self.fetch_size()
65
+ return self._size
66
+
67
+ @property
68
+ def fullsize(self):
69
+ return self._fullsize or self.size
70
+
71
+ def fetch_size(self, *, force: bool | None = False) -> int:
72
+ """retrieve size of source, making sure it's reachable"""
73
+ if not force and self._size >= 0:
74
+ return self._size
75
+ self._size = (
76
+ get_filesize(self.getpath())
77
+ if self.is_local
78
+ else get_online_rsc_size(self.geturl())
79
+ )
80
+ return self._size
81
+
82
+ def geturl(self) -> str:
83
+ """URL as string"""
84
+ return self.url.geturl() if self.url else ""
85
+
86
+ def getpath(self) -> pathlib.Path:
87
+ """URL as a local path"""
88
+ return pathlib.Path(self.url.path if self.url else "").expanduser().resolve()
89
+
90
+ @property
91
+ def is_direct(self):
92
+ return self.via == "direct"
93
+
94
+ @property
95
+ def is_plain(self) -> bool:
96
+ """whether a plain text content to be written"""
97
+ return bool(self.content)
98
+
99
+ @property
100
+ def is_local(self) -> bool:
101
+ """whether referencing a local file"""
102
+ return not self.is_plain and bool(self.url) and self.url.scheme == "file"
103
+
104
+ @property
105
+ def is_remote(self) -> bool:
106
+ """whether referencing a remote file"""
107
+ return not self.content and bool(self.url) and self.url.scheme != "file"
108
+
109
+ def mounted_to(self, mount_point: pathlib.Path):
110
+ """destination (to) path inside mount-point"""
111
+ return mount_point.joinpath(self.to.relative_to(DATA_PART_PATH))
112
+
113
+ def __repr__(self) -> str:
114
+ msg = f"File(to={self.to}, via={self.via}"
115
+ if self.url:
116
+ msg += f", url={self.geturl()}"
117
+ if self.content:
118
+ msg += f", content={self.content.splitlines()[0][:10]}"
119
+ msg += f", size={self.size})"
120
+ return msg
121
+
122
+ def __str__(self) -> str:
123
+ return repr(self)