zboxapi 0.0.7__tar.gz → 0.1.0__tar.gz

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.
@@ -1,3 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: zboxapi
3
+ Version: 0.1.0
4
+ Summary: zPodFactory zBox API: DNS and VLAN management for the zbox VM
5
+ Author: Kelby Valenti, Timo Sugliani
6
+ Author-email: Kelby Valenti <kelby.valenti@gmail.com>, Timo Sugliani <timo.sugliani@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Requires-Dist: fastapi>=0.118
17
+ Requires-Dist: pydantic>=2.12
18
+ Requires-Dist: uvicorn>=0.37
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
1
22
  # zBoxApi
2
23
 
3
24
  zPodFactory zBox Api
@@ -29,6 +50,14 @@ Complete the following steps to set up zBox Api:
29
50
  pipx install zboxapi
30
51
  ```
31
52
 
53
+ Or with [uv](https://docs.astral.sh/uv/), which also fetches a suitable Python if needed:
54
+
55
+ ```bash
56
+ uv tool install zboxapi
57
+ ```
58
+
59
+ zBoxApi supports Python 3.10 through 3.14.
60
+
32
61
  1. Set up and start zboxapi.service
33
62
 
34
63
  ```bash
@@ -112,6 +141,40 @@ curl -X GET "http://127.0.0.1:8000/vlan" \
112
141
  For complete VLAN management documentation, see [DOC_VLAN.md](DOC_VLAN.md).
113
142
 
114
143
 
144
+ ## Development
145
+
146
+ The project is managed with [uv](https://docs.astral.sh/uv/). Clone the repository, then:
147
+
148
+ ```bash
149
+ uv sync # create .venv with the project and dev dependencies
150
+ uv run pytest # run the unit tests (no root, /etc or network access needed)
151
+ uv run pytest --cov # same, with a coverage report
152
+ uv run ruff check src tests && uv run ruff format --check src tests
153
+ ```
154
+
155
+ A `justfile` wraps the same commands (`just test`, `just lint`, `just format`).
156
+
157
+ ### Releasing
158
+
159
+ Every change gets a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). A release is
160
+ one command:
161
+
162
+ ```bash
163
+ python3 tools/release.py 0.2.0 --push # or: just release 0.2.0
164
+ ```
165
+
166
+ It turns `[Unreleased]` into a dated `[0.2.0]` section, bumps `pyproject.toml` and `uv.lock`,
167
+ runs the tests, commits, tags `v0.2.0` and pushes. The tag then runs
168
+ `.github/workflows/release.yml`, which publishes the changelog section as the GitHub release
169
+ note, builds the package with `uv build`, publishes it to PyPI with `uv publish` and attaches
170
+ the wheel and sdist to the release. See [tools/README.md](tools/README.md) for the details,
171
+ including the one-time PyPI setup (an API token secret or trusted publishing).
172
+
173
+ The test suite exercises every endpoint through FastAPI's `TestClient`. The hosts file,
174
+ `/etc/zboxapi.conf`, `/etc/network/interfaces.d/` and the `vmtoolsd` password lookup are
175
+ redirected to temporary locations, and the `ip`, `ifup`, `ifdown` and `pkill` commands are
176
+ replaced by an in-memory fake, so the tests can run on any machine.
177
+
115
178
  ## Documentation
116
179
 
117
180
  - [DOC_DNS.md](DOC_DNS.md) - Complete guide to DNS management features
@@ -1,19 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: zboxapi
3
- Version: 0.0.7
4
- Summary:
5
- Author: Kelby Valenti
6
- Author-email: kelby.valenti@gmail.com
7
- Requires-Python: >=3.10
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.10
10
- Classifier: Programming Language :: Python :: 3.11
11
- Classifier: Programming Language :: Python :: 3.12
12
- Requires-Dist: fastapi (==0.111.0)
13
- Requires-Dist: ipython (>=8.24.0,<9.0.0)
14
- Requires-Dist: uvicorn (==0.29.0)
15
- Description-Content-Type: text/markdown
16
-
17
1
  # zBoxApi
18
2
 
19
3
  zPodFactory zBox Api
@@ -45,6 +29,14 @@ Complete the following steps to set up zBox Api:
45
29
  pipx install zboxapi
46
30
  ```
47
31
 
32
+ Or with [uv](https://docs.astral.sh/uv/), which also fetches a suitable Python if needed:
33
+
34
+ ```bash
35
+ uv tool install zboxapi
36
+ ```
37
+
38
+ zBoxApi supports Python 3.10 through 3.14.
39
+
48
40
  1. Set up and start zboxapi.service
49
41
 
50
42
  ```bash
@@ -128,8 +120,41 @@ curl -X GET "http://127.0.0.1:8000/vlan" \
128
120
  For complete VLAN management documentation, see [DOC_VLAN.md](DOC_VLAN.md).
129
121
 
130
122
 
123
+ ## Development
124
+
125
+ The project is managed with [uv](https://docs.astral.sh/uv/). Clone the repository, then:
126
+
127
+ ```bash
128
+ uv sync # create .venv with the project and dev dependencies
129
+ uv run pytest # run the unit tests (no root, /etc or network access needed)
130
+ uv run pytest --cov # same, with a coverage report
131
+ uv run ruff check src tests && uv run ruff format --check src tests
132
+ ```
133
+
134
+ A `justfile` wraps the same commands (`just test`, `just lint`, `just format`).
135
+
136
+ ### Releasing
137
+
138
+ Every change gets a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). A release is
139
+ one command:
140
+
141
+ ```bash
142
+ python3 tools/release.py 0.2.0 --push # or: just release 0.2.0
143
+ ```
144
+
145
+ It turns `[Unreleased]` into a dated `[0.2.0]` section, bumps `pyproject.toml` and `uv.lock`,
146
+ runs the tests, commits, tags `v0.2.0` and pushes. The tag then runs
147
+ `.github/workflows/release.yml`, which publishes the changelog section as the GitHub release
148
+ note, builds the package with `uv build`, publishes it to PyPI with `uv publish` and attaches
149
+ the wheel and sdist to the release. See [tools/README.md](tools/README.md) for the details,
150
+ including the one-time PyPI setup (an API token secret or trusted publishing).
151
+
152
+ The test suite exercises every endpoint through FastAPI's `TestClient`. The hosts file,
153
+ `/etc/zboxapi.conf`, `/etc/network/interfaces.d/` and the `vmtoolsd` password lookup are
154
+ redirected to temporary locations, and the `ip`, `ifup`, `ifdown` and `pkill` commands are
155
+ replaced by an in-memory fake, so the tests can run on any machine.
156
+
131
157
  ## Documentation
132
158
 
133
159
  - [DOC_DNS.md](DOC_DNS.md) - Complete guide to DNS management features
134
160
  - [DOC_VLAN.md](DOC_VLAN.md) - Complete guide to VLAN management features
135
-
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "zboxapi"
3
+ version = "0.1.0"
4
+ description = "zPodFactory zBox API: DNS and VLAN management for the zbox VM"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ classifiers = [
9
+ "Programming Language :: Python :: 3",
10
+ "Programming Language :: Python :: 3.10",
11
+ "Programming Language :: Python :: 3.11",
12
+ "Programming Language :: Python :: 3.12",
13
+ "Programming Language :: Python :: 3.13",
14
+ "Programming Language :: Python :: 3.14",
15
+ "Framework :: FastAPI",
16
+ "Operating System :: POSIX :: Linux",
17
+ ]
18
+ dependencies = [
19
+ "fastapi>=0.118",
20
+ "pydantic>=2.12",
21
+ "uvicorn>=0.37",
22
+ ]
23
+
24
+ [[project.authors]]
25
+ name = "Kelby Valenti"
26
+ email = "kelby.valenti@gmail.com"
27
+
28
+ [[project.authors]]
29
+ name = "Timo Sugliani"
30
+ email = "timo.sugliani@gmail.com"
31
+
32
+ [project.scripts]
33
+ zboxapi = "zboxapi.main:launch"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "httpx2>=0.1",
38
+ "ipython>=8.24",
39
+ "pytest>=8.3",
40
+ "pytest-cov>=6.0",
41
+ "ruff>=0.13",
42
+ ]
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.9,<1"]
46
+ build-backend = "uv_build"
47
+
48
+ [tool.uv]
49
+ required-version = ">=0.9"
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ addopts = "-ra"
54
+
55
+ [tool.coverage.run]
56
+ source = ["zboxapi"]
57
+
58
+ [tool.ruff]
59
+ target-version = "py310"
60
+ line-length = 88
61
+
62
+ [tool.ruff.lint]
63
+ select = [
64
+ "E",
65
+ "W",
66
+ "F",
67
+ "I",
68
+ "C",
69
+ "B",
70
+ "UP",
71
+ ]
72
+ ignore = ["B008"]
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "zboxapi"
3
+ version = "0.1.0"
4
+ description = "zPodFactory zBox API: DNS and VLAN management for the zbox VM"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ { name = "Kelby Valenti", email = "kelby.valenti@gmail.com" },
10
+ { name = "Timo Sugliani", email = "timo.sugliani@gmail.com" },
11
+ ]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Framework :: FastAPI",
20
+ "Operating System :: POSIX :: Linux",
21
+ ]
22
+ dependencies = [
23
+ "fastapi>=0.118",
24
+ "pydantic>=2.12",
25
+ "uvicorn>=0.37",
26
+ ]
27
+
28
+ [project.scripts]
29
+ zboxapi = "zboxapi.main:launch"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "httpx2>=0.1",
34
+ "ipython>=8.24",
35
+ "pytest>=8.3",
36
+ "pytest-cov>=6.0",
37
+ "ruff>=0.13",
38
+ ]
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.9,<1"]
42
+ build-backend = "uv_build"
43
+
44
+ [tool.uv]
45
+ required-version = ">=0.9"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ addopts = "-ra"
50
+
51
+ [tool.coverage.run]
52
+ source = ["zboxapi"]
53
+
54
+ [tool.ruff]
55
+ target-version = "py310"
56
+ line-length = 88
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ "E", # pycodestyle errors
61
+ "W", # pycodestyle warnings
62
+ "F", # pyflakes
63
+ "I", # isort
64
+ "C", # flake8-comprehensions
65
+ "B", # flake8-bugbear
66
+ "UP", # pyupgrade
67
+ ]
68
+ ignore = [
69
+ "B008",
70
+ ]
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("zboxapi")
5
+ except PackageNotFoundError: # pragma: no cover - running from a bare checkout
6
+ __version__ = "0.0.0"
@@ -12,11 +12,14 @@ from fastapi import APIRouter, HTTPException, status
12
12
  from pydantic import AfterValidator, BaseModel
13
13
  from pydantic_core import PydanticCustomError
14
14
 
15
+ # Path of the hosts file managed by this API (module-level so tests can override)
16
+ HOSTS_FILE = Path("/etc/hosts")
17
+
15
18
 
16
19
  @contextlib.contextmanager
17
20
  def get_hosts_file_object():
18
- """Context manager for safely handling /etc/hosts file"""
19
- pfile = Path("/etc/hosts")
21
+ """Context manager for safely handling the hosts file"""
22
+ pfile = HOSTS_FILE
20
23
  if not pfile.is_file():
21
24
  pfile.write_text("")
22
25
 
@@ -25,7 +28,7 @@ def get_hosts_file_object():
25
28
  file_handle = pfile.open("r+")
26
29
  fcntl.flock(file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
27
30
  break
28
- except IOError: # noqa: UP024
31
+ except OSError:
29
32
  # File is locked, wait for a while and try again
30
33
  time.sleep(0.1)
31
34
 
@@ -105,19 +108,37 @@ def RecordAlreadyPresent(ip, hostname):
105
108
 
106
109
 
107
110
  def validate_hostname(value: str):
108
- """Validate hostname format"""
109
- if not 1 <= len(value) < 64:
110
- raise PydanticCustomError("value_error", "Invalid hostname length")
111
-
112
- # Define pattern of DNS label
113
- # Can begin and end with a number or letter only
114
- # Can contain hyphens, a-z, A-Z, 0-9
115
- # 1 - 63 chars allowed
116
- hostname_re = re.compile(r"^[a-z0-9]([a-z-0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
117
-
118
- # Check that all labels match that pattern.
119
- if not hostname_re.match(value):
120
- raise PydanticCustomError("value_error", "Invalid hostname")
111
+ """Validate hostname format according to DNS standards"""
112
+ # Check total FQDN length (1-253 characters)
113
+ if not 1 <= len(value) <= 253:
114
+ raise PydanticCustomError(
115
+ "value_error",
116
+ f"Invalid hostname length: {len(value)} characters (must be 1-253)",
117
+ )
118
+
119
+ # Define pattern for individual DNS labels
120
+ # Each label: 1-63 chars, alphanumeric + hyphens, can't start/end with hyphen
121
+ label_re = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
122
+
123
+ # Check that all labels match the pattern and length requirements
124
+ for label in value.split("."):
125
+ if not label: # Empty label (leading/trailing/consecutive dots)
126
+ raise PydanticCustomError("value_error", "Invalid hostname: empty label")
127
+
128
+ # Check individual label length (1-63 characters)
129
+ if len(label) > 63:
130
+ raise PydanticCustomError(
131
+ "value_error",
132
+ f"Invalid label length: '{label}' is {len(label)} characters "
133
+ "(must be 1-63)",
134
+ )
135
+
136
+ # Check label format (alphanumeric + hyphens, can't start/end with hyphen)
137
+ if not label_re.match(label):
138
+ raise PydanticCustomError(
139
+ "value_error", f"Invalid hostname label: '{label}'"
140
+ )
141
+
121
142
  return value
122
143
 
123
144
 
@@ -1,12 +1,15 @@
1
+ import functools
1
2
  import os
2
3
  import re
3
4
  import subprocess
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
4
7
  from typing import Annotated
5
8
 
6
9
  import uvicorn
7
10
  from fastapi import Depends, FastAPI, HTTPException, Security, status
8
11
  from fastapi.routing import APIRoute
9
- from fastapi.security.api_key import APIKey, APIKeyHeader
12
+ from fastapi.security.api_key import APIKeyHeader
10
13
 
11
14
  from zboxapi import __version__
12
15
  from zboxapi.dns import dns_router
@@ -15,17 +18,9 @@ from zboxapi.vlan import vlan_router
15
18
  api_key_header = APIKeyHeader(name="access_token", auto_error=False)
16
19
 
17
20
 
18
- def validate_api_key(api_key: Annotated[APIKey, Security(api_key_header)]):
19
- """Validate API key for authentication"""
20
- if api_key != ZPOD_PASSWORD:
21
- raise HTTPException(
22
- status_code=status.HTTP_403_FORBIDDEN,
23
- detail="Invalid access_token",
24
- )
25
-
26
-
27
- def get_zpod_password():
28
- """Retrieve zpod password from VMware tools"""
21
+ @functools.cache
22
+ def get_zpod_password() -> str:
23
+ """Retrieve zpod password from VMware tools (cached after first lookup)"""
29
24
  ovfenv = subprocess.run(
30
25
  ["vmtoolsd", "--cmd", "info-get guestinfo.ovfenv"],
31
26
  capture_output=True,
@@ -37,19 +32,30 @@ def get_zpod_password():
37
32
  raise Exception("Unable to retrieve zpod password")
38
33
 
39
34
 
40
- def simplify_operation_ids(api: FastAPI) -> None:
35
+ def validate_api_key(api_key: Annotated[str | None, Security(api_key_header)]):
36
+ """Validate API key for authentication"""
37
+ if api_key != get_zpod_password():
38
+ raise HTTPException(
39
+ status_code=status.HTTP_403_FORBIDDEN,
40
+ detail="Invalid access_token",
41
+ )
42
+
43
+
44
+ def generate_operation_id(route: APIRoute) -> str:
41
45
  """
42
- Update operation IDs so that generated API clients have simpler function
43
- names.
46
+ Build operation IDs as "<tag>_<function name>" so that generated API
47
+ clients have simpler function names (e.g. dns_dns_get_all).
44
48
  """
45
- for route in api.routes:
46
- if isinstance(route, APIRoute) and not route.operation_id:
47
- tag = route.tags[0] if route.tags else "default"
48
- route.operation_id = f"{tag}_{route.name}"
49
+ tag = route.tags[0] if route.tags else "default"
50
+ return f"{tag}_{route.name}"
49
51
 
50
52
 
51
- # Get zpod password for authentication
52
- ZPOD_PASSWORD = get_zpod_password()
53
+ @asynccontextmanager
54
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
55
+ """Resolve the zpod password at startup so a broken VM env fails fast"""
56
+ get_zpod_password()
57
+ yield
58
+
53
59
 
54
60
  # Get root path from environment
55
61
  zboxapi_root_path = os.getenv("ZBOXAPI_ROOT_PATH", None)
@@ -60,15 +66,14 @@ app = FastAPI(
60
66
  root_path=zboxapi_root_path,
61
67
  dependencies=[Depends(validate_api_key)],
62
68
  version=__version__,
69
+ lifespan=lifespan,
70
+ generate_unique_id_function=generate_operation_id,
63
71
  )
64
72
 
65
73
  # Include routers
66
74
  app.include_router(dns_router)
67
75
  app.include_router(vlan_router)
68
76
 
69
- # Simplify operation IDs
70
- simplify_operation_ids(app)
71
-
72
77
 
73
78
  def launch():
74
79
  """Launch the FastAPI application with uvicorn"""
@@ -12,6 +12,10 @@ from fastapi import APIRouter, HTTPException, status
12
12
  from pydantic import AfterValidator, BaseModel, Field
13
13
  from pydantic_core import PydanticCustomError
14
14
 
15
+ # Paths managed by this API (module-level so tests can override)
16
+ CONFIG_FILE = Path("/etc/zboxapi.conf")
17
+ INTERFACES_DIR = Path("/etc/network/interfaces.d")
18
+
15
19
 
16
20
  class ConfigError(Exception):
17
21
  """Configuration error"""
@@ -28,17 +32,20 @@ class NetworkError(Exception):
28
32
  def load_config() -> configparser.ConfigParser:
29
33
  """Load configuration from /etc/zboxapi.conf"""
30
34
  config = configparser.ConfigParser()
31
- config_path = Path("/etc/zboxapi.conf")
35
+ config_path = CONFIG_FILE
32
36
 
33
37
  if not config_path.exists():
34
- raise ConfigError("Configuration file /etc/zboxapi.conf not found")
38
+ raise ConfigError(f"Configuration file {config_path} not found")
35
39
 
36
40
  config.read(config_path)
37
41
  return config
38
42
 
39
43
 
40
44
  def get_config_value(
41
- config: configparser.ConfigParser, section: str, key: str, default: str = None
45
+ config: configparser.ConfigParser,
46
+ section: str,
47
+ key: str,
48
+ default: str | None = None,
42
49
  ) -> str:
43
50
  """Get configuration value with optional default"""
44
51
  try:
@@ -115,7 +122,7 @@ def validate_cidr(cidr: str) -> str:
115
122
  """Validate CIDR notation, but preserve the original input."""
116
123
  try:
117
124
  # Parse the CIDR to validate it's correct
118
- network = ipaddress.IPv4Network(cidr, strict=False)
125
+ ipaddress.IPv4Network(cidr, strict=False)
119
126
  # But return the original input, not the normalized network
120
127
  return cidr
121
128
  except ValueError as e:
@@ -150,7 +157,9 @@ def check_no_overlap(cidrs):
150
157
  return True
151
158
 
152
159
 
153
- def validate_vlan_networks(new_gateway: str, exclude_vlan_id: int = None) -> None:
160
+ def validate_vlan_networks(
161
+ new_gateway: str, exclude_vlan_id: int | None = None
162
+ ) -> None:
154
163
  """Validate that a new gateway doesn't overlap with existing VLAN networks"""
155
164
  # Get all existing VLAN gateways
156
165
  existing_vlans = get_existing_vlans()
@@ -160,6 +169,13 @@ def validate_vlan_networks(new_gateway: str, exclude_vlan_id: int = None) -> Non
160
169
  # Skip the VLAN we're updating (if this is an update operation)
161
170
  if exclude_vlan_id is not None and vlan.vlan == exclude_vlan_id:
162
171
  continue
172
+ # System VLANs whose interface has no address carry a placeholder
173
+ # ("system-default" / "system-zpod") instead of a CIDR: nothing to
174
+ # compare against, so leave them out of the overlap check.
175
+ try:
176
+ ipaddress.ip_network(vlan.gateway, strict=False)
177
+ except ValueError:
178
+ continue
163
179
  all_gateways.append(vlan.gateway)
164
180
 
165
181
  # Check for overlaps
@@ -192,9 +208,9 @@ class VlanView(BaseModel):
192
208
 
193
209
  @contextlib.contextmanager
194
210
  def get_vlan_config_file_object(vlan_id: int):
195
- """Context manager for safely handling VLAN configuration files in /etc/network/interfaces.d/"""
211
+ """Context manager for safely handling VLAN config files in interfaces.d/"""
196
212
  interface_name = get_interface_name()
197
- config_dir = Path("/etc/network/interfaces.d")
213
+ config_dir = INTERFACES_DIR
198
214
  config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
199
215
 
200
216
  # Ensure the directory exists
@@ -205,7 +221,7 @@ def get_vlan_config_file_object(vlan_id: int):
205
221
  file_handle = config_file.open("w") # Use write mode for individual files
206
222
  fcntl.flock(file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
207
223
  break
208
- except IOError:
224
+ except OSError:
209
225
  time.sleep(0.1)
210
226
 
211
227
  try:
@@ -215,108 +231,95 @@ def get_vlan_config_file_object(vlan_id: int):
215
231
  file_handle.close()
216
232
 
217
233
 
218
- def get_existing_vlans() -> list[VlanView]:
219
- """Get all existing VLAN configurations from /etc/network/interfaces.d/ and system VLANs"""
220
- interface_name = get_interface_name()
221
- config_dir = Path("/etc/network/interfaces.d")
234
+ def get_interface_status(interface_name_full: str) -> str:
235
+ """Return up if the interface exists and is up, otherwise down"""
236
+ try:
237
+ result = subprocess.run(
238
+ ["ip", "link", "show", interface_name_full],
239
+ capture_output=True,
240
+ text=True,
241
+ check=False,
242
+ )
243
+ if result.returncode == 0 and "UP" in result.stdout:
244
+ return "up"
245
+ except Exception:
246
+ pass
247
+ return "down"
222
248
 
223
- vlans = []
224
249
 
225
- # Add system default VLANs
226
- system_vlans_default = get_system_vlans_default()
227
- for vlan_id in system_vlans_default:
228
- # Try to get actual gateway from ip addr command
229
- gateway = "system-default"
230
- interface_name_full = f"{interface_name}.{vlan_id}"
250
+ def get_interface_gateway(interface_name_full: str) -> str | None:
251
+ """Return the first inet address (CIDR) of an interface, if any"""
252
+ try:
253
+ result = subprocess.run(
254
+ ["ip", "addr", "show", interface_name_full],
255
+ capture_output=True,
256
+ text=True,
257
+ check=False,
258
+ )
259
+ if result.returncode == 0:
260
+ ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+/\d+)", result.stdout)
261
+ if ip_match:
262
+ return ip_match.group(1)
263
+ except Exception:
264
+ pass
265
+ return None
231
266
 
232
- # Check if interface is currently up
233
- status = "down"
234
- try:
235
- result = subprocess.run(
236
- ["ip", "link", "show", interface_name_full],
237
- capture_output=True,
238
- text=True,
239
- check=False,
240
- )
241
- if result.returncode == 0 and "UP" in result.stdout:
242
- status = "up"
243
- except Exception:
244
- pass
245
267
 
246
- try:
247
- result = subprocess.run(
248
- ["ip", "addr", "show", interface_name_full],
249
- capture_output=True,
250
- text=True,
251
- check=False,
252
- )
253
- if result.returncode == 0:
254
- # Extract IP address from ip addr output
255
- ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+/\d+)", result.stdout)
256
- if ip_match:
257
- gateway = ip_match.group(1)
258
- except Exception:
259
- pass
260
-
261
- vlans.append(
262
- VlanView(
263
- vlan=vlan_id,
264
- gateway=gateway,
265
- interface=interface_name_full,
266
- status=status,
267
- owner="system-default",
268
- )
269
- )
268
+ def get_system_vlan_view(interface_name: str, vlan_id: int, owner: str) -> VlanView:
269
+ """Build the view of a system VLAN from the live interface state"""
270
+ interface_name_full = f"{interface_name}.{vlan_id}"
271
+ return VlanView(
272
+ vlan=vlan_id,
273
+ gateway=get_interface_gateway(interface_name_full) or owner,
274
+ interface=interface_name_full,
275
+ status=get_interface_status(interface_name_full),
276
+ owner=owner,
277
+ )
270
278
 
271
- # Add system zPod VLANs
272
- system_vlans_zpod = get_system_vlans_zpod()
273
- for vlan_id in system_vlans_zpod:
274
- # Try to get actual gateway from ip addr command
275
- gateway = "system-zpod"
276
- interface_name_full = f"{interface_name}.{vlan_id}"
277
279
 
278
- # Check if interface is currently up
279
- status = "down"
280
- try:
281
- result = subprocess.run(
282
- ["ip", "link", "show", interface_name_full],
283
- capture_output=True,
284
- text=True,
285
- check=False,
286
- )
287
- if result.returncode == 0 and "UP" in result.stdout:
288
- status = "up"
289
- except Exception:
290
- pass
280
+ def get_user_vlan_view(
281
+ interface_name: str, vlan_id: int, config_file: Path
282
+ ) -> VlanView | None:
283
+ """Build the view of a user-defined VLAN from its interfaces.d config file"""
284
+ try:
285
+ content = config_file.read_text()
286
+ except OSError:
287
+ return None
291
288
 
292
- try:
293
- result = subprocess.run(
294
- ["ip", "addr", "show", interface_name_full],
295
- capture_output=True,
296
- text=True,
297
- check=False,
298
- )
299
- if result.returncode == 0:
300
- # Extract IP address from ip addr output
301
- ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+/\d+)", result.stdout)
302
- if ip_match:
303
- gateway = ip_match.group(1)
304
- except Exception:
305
- pass
306
-
307
- vlans.append(
308
- VlanView(
309
- vlan=vlan_id,
310
- gateway=gateway,
311
- interface=interface_name_full,
312
- status=status,
313
- owner="system-zpod",
314
- )
315
- )
289
+ # Extract gateway from the configuration
290
+ gateway_match = re.search(r"address\s+(\d+\.\d+\.\d+\.\d+/\d+)", content)
291
+ if not gateway_match:
292
+ return None
293
+
294
+ interface_name_full = f"{interface_name}.{vlan_id}"
295
+ return VlanView(
296
+ vlan=vlan_id,
297
+ gateway=gateway_match.group(1),
298
+ interface=interface_name_full,
299
+ status=get_interface_status(interface_name_full),
300
+ owner="user-defined",
301
+ )
316
302
 
317
- # Add user-configured VLANs from /etc/network/interfaces.d/
303
+
304
+ def get_existing_vlans() -> list[VlanView]:
305
+ """Get all VLANs: system VLANs from config plus user VLANs from interfaces.d/"""
306
+ interface_name = get_interface_name()
307
+ config_dir = INTERFACES_DIR
308
+
309
+ system_vlans_default = get_system_vlans_default()
310
+ system_vlans_zpod = get_system_vlans_zpod()
311
+
312
+ vlans = [
313
+ get_system_vlan_view(interface_name, vlan_id, "system-default")
314
+ for vlan_id in system_vlans_default
315
+ ]
316
+ vlans.extend(
317
+ get_system_vlan_view(interface_name, vlan_id, "system-zpod")
318
+ for vlan_id in system_vlans_zpod
319
+ )
320
+
321
+ # Add user-configured VLANs from interfaces.d/
318
322
  if config_dir.exists():
319
- # Find all VLAN configuration files
320
323
  vlan_pattern = rf"{re.escape(interface_name)}\.(\d+)\.cfg$"
321
324
 
322
325
  for config_file in config_dir.glob(f"{interface_name}.*.cfg"):
@@ -330,44 +333,8 @@ def get_existing_vlans() -> list[VlanView]:
330
333
  if vlan_id in system_vlans_default or vlan_id in system_vlans_zpod:
331
334
  continue
332
335
 
333
- try:
334
- with open(config_file, "r") as f:
335
- content = f.read()
336
-
337
- # Extract gateway from the configuration
338
- gateway_match = re.search(
339
- r"address\s+(\d+\.\d+\.\d+\.\d+/\d+)", content
340
- )
341
- if not gateway_match:
342
- continue
343
-
344
- gateway = gateway_match.group(1)
345
-
346
- # Check if interface is currently up
347
- status = "down"
348
- try:
349
- result = subprocess.run(
350
- ["ip", "link", "show", f"{interface_name}.{vlan_id}"],
351
- capture_output=True,
352
- text=True,
353
- check=False,
354
- )
355
- if result.returncode == 0 and "UP" in result.stdout:
356
- status = "up"
357
- except Exception:
358
- pass
359
-
360
- vlans.append(
361
- VlanView(
362
- vlan=vlan_id,
363
- gateway=gateway,
364
- interface=f"{interface_name}.{vlan_id}",
365
- status=status,
366
- owner="user-defined",
367
- )
368
- )
369
- except Exception:
370
- continue # Skip files that can't be read or parsed
336
+ if view := get_user_vlan_view(interface_name, vlan_id, config_file):
337
+ vlans.append(view)
371
338
 
372
339
  return sorted(vlans, key=lambda x: x.vlan)
373
340
 
@@ -381,8 +348,7 @@ def add_vlan_interface(vlan_id: int, gateway: str) -> None:
381
348
  validate_vlan_networks(gateway)
382
349
 
383
350
  # Check if VLAN configuration file already exists
384
- config_dir = Path("/etc/network/interfaces.d")
385
- config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
351
+ config_file = INTERFACES_DIR / f"{interface_name}.{vlan_id}.cfg"
386
352
 
387
353
  if config_file.exists():
388
354
  raise NetworkError(f"VLAN interface {interface_name}.{vlan_id} already exists")
@@ -404,12 +370,11 @@ def update_vlan_interface(vlan_id: int, gateway: str) -> None:
404
370
  interface_name = get_interface_name()
405
371
  mtu = get_mtu()
406
372
 
407
- # Validate inputs - check for overlaps and gateway uniqueness, excluding current VLAN
373
+ # Validate inputs - check for overlaps, excluding the VLAN being updated
408
374
  validate_vlan_networks(gateway, exclude_vlan_id=vlan_id)
409
375
 
410
376
  # Check if VLAN configuration file exists
411
- config_dir = Path("/etc/network/interfaces.d")
412
- config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
377
+ config_file = INTERFACES_DIR / f"{interface_name}.{vlan_id}.cfg"
413
378
 
414
379
  if not config_file.exists():
415
380
  raise NetworkError(f"VLAN interface {interface_name}.{vlan_id} does not exist")
@@ -429,8 +394,7 @@ iface {interface_name}.{vlan_id} inet static
429
394
  def delete_vlan_interface(vlan_id: int) -> None:
430
395
  """Delete VLAN interface configuration from /etc/network/interfaces.d/"""
431
396
  interface_name = get_interface_name()
432
- config_dir = Path("/etc/network/interfaces.d")
433
- config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
397
+ config_file = INTERFACES_DIR / f"{interface_name}.{vlan_id}.cfg"
434
398
 
435
399
  if not config_file.exists():
436
400
  raise NetworkError(f"VLAN interface {interface_name}.{vlan_id} does not exist")
@@ -572,6 +536,9 @@ def vlan_update(vlan_id: int, vlan_in: VlanUpdate) -> VlanView:
572
536
  status="up",
573
537
  owner="user-defined",
574
538
  )
539
+ except PydanticCustomError as e:
540
+ # System VLANs are forbidden from modification
541
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
575
542
  except (ConfigError, NetworkError) as e:
576
543
  raise HTTPException(
577
544
  status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
@@ -590,10 +557,13 @@ def vlan_delete(vlan_id: int) -> dict:
590
557
  # Validate VLAN ID
591
558
  validate_vlan_id(vlan_id)
592
559
 
593
- # Delete VLAN interface configuration (includes bringing down and deleting interface)
560
+ # Delete VLAN configuration (brings the interface down first)
594
561
  delete_vlan_interface(vlan_id)
595
562
 
596
563
  return {"message": f"VLAN {vlan_id} deleted successfully"}
564
+ except PydanticCustomError as e:
565
+ # System VLANs are forbidden from modification
566
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
597
567
  except (ConfigError, NetworkError) as e:
598
568
  raise HTTPException(
599
569
  status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
@@ -1,43 +0,0 @@
1
- [tool.poetry]
2
- name = "zboxapi"
3
- version = "0.0.7"
4
- description = ""
5
- authors = ["Kelby Valenti <kelby.valenti@gmail.com>", "Timo Sugliani <timo.sugliani@gmail.com>"]
6
- readme = "README.md"
7
-
8
- [tool.poetry.scripts]
9
- zboxapi = "zboxapi.main:launch"
10
-
11
- [tool.poetry.dependencies]
12
- fastapi = "0.111.0"
13
- python = ">=3.10"
14
- uvicorn = "0.29.0"
15
- ipython = "^8.24.0"
16
-
17
- [tool.poetry.group.dev.dependencies]
18
- ruff = "^0.4"
19
-
20
- [build-system]
21
- requires = ["poetry-core"]
22
- build-backend = "poetry.core.masonry.api"
23
-
24
- [tool.ruff.lint]
25
- select = [
26
- "E", # pycodestyle errors
27
- "W", # pycodestyle warnings
28
- "F", # pyflakes
29
- "I", # isort
30
- "C", # flake8-comprehensions
31
- "B", # flake8-bugbear
32
- "UP", # pyupgrade
33
- ]
34
- ignore = [
35
- "B008",
36
- ]
37
-
38
- [[tool.poetry_bumpversion.replacements]]
39
- files = [
40
- "src/zboxapi/__init__.py",
41
- ]
42
- search = '__version__ = "{current_version}"'
43
- replace = '__version__ = "{new_version}"'
@@ -1 +0,0 @@
1
- __version__ = "0.0.7"