Skip to content

Commit 5272eb8

Browse files
committed
feat: add delta command, shows state drift between configs and apt
1 parent 92f4c63 commit 5272eb8

15 files changed

+433
-168
lines changed

src/ops2deb/apt.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class DebianRepository(BaseModel):
1717
distribution: str = Field(..., regex=r"[a-zA-Z0-9]+")
1818

1919

20-
@dataclass
20+
@dataclass(frozen=True, order=True)
2121
class DebianRepositoryPackage:
2222
name: str
2323
version: str
@@ -69,15 +69,15 @@ def _parse_debian_repository_option(debian_repository: str) -> DebianRepository:
6969
raise Ops2debAptError(str(e))
7070

7171

72-
async def list_repository_packages(
72+
async def _list_repository_packages(
7373
debian_repository: str,
7474
) -> list[DebianRepositoryPackage]:
7575
repository = _parse_debian_repository_option(debian_repository)
7676
async with client_factory(base_url=repository.url) as client:
7777
release = await _download_repository_release_file(client, repository.distribution)
7878
architectures = release["Architectures"].split(" ")
7979
components = release["Components"].split(" ")
80-
logger.info(
80+
logger.debug(
8181
f"Repository {repository.url} {repository.distribution} has architectures "
8282
f"{architectures} and components {components}"
8383
)
@@ -93,8 +93,8 @@ async def list_repository_packages(
9393
return list(chain(*results))
9494

9595

96-
def sync_list_repository_packages(
96+
def list_repository_packages(
9797
debian_repository: str,
9898
) -> list[DebianRepositoryPackage]:
9999
"""Example: "http://deb.wakemeops.com/ stable" """
100-
return asyncio.run(list_repository_packages(debian_repository))
100+
return asyncio.run(_list_repository_packages(debian_repository))

src/ops2deb/builder.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def parse_debian_control(cwd: Path) -> dict[str, str]:
2727
return control
2828

2929

30-
async def build_package(cwd: Path) -> None:
30+
async def build_source_package(cwd: Path) -> None:
3131
"""Run dpkg-buildpackage in specified path."""
3232
args = ["-us", "-uc"]
3333
arch = parse_debian_control(cwd)["Architecture"]
@@ -56,7 +56,7 @@ async def build_package(cwd: Path) -> None:
5656
logger.info(f"Successfully built {str(cwd)}")
5757

5858

59-
def build(package_paths: list[Path], workers: int) -> None:
59+
def build_source_packages(package_paths: list[Path], workers: int) -> None:
6060
"""
6161
Build debian source packages in parallel.
6262
:param package_paths: list of debian source package paths
@@ -67,7 +67,7 @@ def build(package_paths: list[Path], workers: int) -> None:
6767

6868
async def _build_package(sem: asyncio.Semaphore, _path: Path) -> None:
6969
async with sem: # semaphore limits num of simultaneous builds
70-
await build_package(_path)
70+
await build_source_package(_path)
7171

7272
async def _build_packages() -> Any:
7373
sem = asyncio.Semaphore(workers)
@@ -81,7 +81,7 @@ async def _build_packages() -> Any:
8181
raise Ops2debBuilderError(f"{len(errors)} failures occurred")
8282

8383

84-
def build_all(output_directory: Path, workers: int) -> None:
84+
def find_and_build_source_packages(output_directory: Path, workers: int) -> None:
8585
"""
8686
Build debian source packages in parallel.
8787
:param output_directory: path where to search for source packages
@@ -97,4 +97,4 @@ def build_all(output_directory: Path, workers: int) -> None:
9797
for output_directory in output_directory.iterdir():
9898
if output_directory.is_dir() and (output_directory / "debian/control").is_file():
9999
paths.append(output_directory)
100-
build(paths, workers)
100+
build_source_packages(paths, workers)

src/ops2deb/cli.py

+88-34
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66

77
import click
88
import typer
9+
from rich.console import Console
10+
from rich.table import Table
911
from typer.core import TyperGroup
1012

11-
from ops2deb import __version__, builder, formatter, generator, logger, updater
13+
from ops2deb import __version__, generator, logger, updater
14+
from ops2deb.apt import list_repository_packages
15+
from ops2deb.builder import build_source_packages, find_and_build_source_packages
16+
from ops2deb.delta import StateDelta, compute_state_delta
1217
from ops2deb.exceptions import Ops2debError
1318
from ops2deb.fetcher import DEFAULT_CACHE_DIRECTORY, Fetcher
14-
from ops2deb.parser import load_configuration
19+
from ops2deb.formatter import format_all
20+
from ops2deb.parser import Resources, load_resources
1521

1622

1723
class DefaultCommandGroup(TyperGroup):
@@ -51,12 +57,6 @@ def validate_exit_code(exit_code: int) -> int:
5157
return exit_code
5258

5359

54-
def error(exception: Exception, exit_code: int) -> NoReturn:
55-
logger.error(str(exception))
56-
logger.debug(traceback.format_exc())
57-
sys.exit(exit_code)
58-
59-
6060
option_verbose: bool = typer.Option(
6161
False,
6262
"--verbose",
@@ -75,7 +75,7 @@ def error(exception: Exception, exit_code: int) -> NoReturn:
7575
callback=validate_exit_code,
7676
)
7777

78-
option_search_glob: str = typer.Option(
78+
option_configurations_search_pattern: str = typer.Option(
7979
"./**/ops2deb.yml",
8080
"--config",
8181
"-c",
@@ -127,47 +127,72 @@ def error(exception: Exception, exit_code: int) -> NoReturn:
127127
app = typer.Typer(cls=DefaultCommandGroup)
128128

129129

130+
def error(exception: Exception, exit_code: int) -> NoReturn:
131+
logger.error(str(exception))
132+
logger.debug(traceback.format_exc())
133+
sys.exit(exit_code)
134+
135+
136+
def print_loaded_resources(resources: Resources) -> None:
137+
logger.title(
138+
f"Loaded {len(resources.configuration_files)} configuration file(s) and "
139+
f"{len(resources.blueprints)} blueprint(s)"
140+
)
141+
142+
143+
def print_state_delta_as_rich_table(state_delta: StateDelta) -> None:
144+
table = Table(box=None, pad_edge=False, show_header=False)
145+
for package in state_delta.removed:
146+
table.add_row("[red]-[/]", package.name, package.version, package.architecture)
147+
for package in state_delta.added:
148+
table.add_row("[green]+[/]", package.name, package.version, package.architecture)
149+
console = Console()
150+
console.print(table)
151+
152+
130153
@app.command(help="Generate and build source packages.")
131154
def default(
132155
verbose: bool = option_verbose,
133156
exit_code: int = option_exit_code,
134-
search_glob: str = option_search_glob,
157+
configurations_search_pattern: str = option_configurations_search_pattern,
135158
output_directory: Path = option_output_directory,
136159
cache_directory: Path = option_cache_directory,
137160
debian_repository: Optional[str] = option_debian_repository,
138161
only: Optional[List[str]] = option_only,
139162
workers_count: int = option_workers_count,
140163
) -> None:
141164
try:
142-
configuration = load_configuration(search_glob)
165+
resources = load_resources(configurations_search_pattern)
166+
print_loaded_resources(resources)
143167
fetcher = Fetcher(cache_directory)
144168
packages = generator.generate(
145-
configuration,
169+
resources,
146170
fetcher,
147171
output_directory,
148172
debian_repository,
149173
only or None,
150174
)
151-
builder.build([p.package_directory for p in packages], workers_count)
175+
build_source_packages([p.package_directory for p in packages], workers_count)
152176
except Ops2debError as e:
153177
error(e, exit_code)
154178

155179

156-
@app.command(help="Generate debian source packages from configuration file.")
180+
@app.command(help="Generate debian source packages from configuration files.")
157181
def generate(
158182
verbose: bool = option_verbose,
159183
exit_code: int = option_exit_code,
160-
search_glob: str = option_search_glob,
184+
configurations_search_pattern: str = option_configurations_search_pattern,
161185
output_directory: Path = option_output_directory,
162186
cache_directory: Path = option_cache_directory,
163187
debian_repository: Optional[str] = option_debian_repository,
164188
only: Optional[List[str]] = option_only,
165189
) -> None:
166190
try:
167-
configuration = load_configuration(search_glob)
191+
resources = load_resources(configurations_search_pattern)
192+
print_loaded_resources(resources)
168193
fetcher = Fetcher(cache_directory)
169194
generator.generate(
170-
configuration,
195+
resources,
171196
fetcher,
172197
output_directory,
173198
debian_repository,
@@ -185,7 +210,7 @@ def build(
185210
workers_count: int = option_workers_count,
186211
) -> None:
187212
try:
188-
builder.build_all(output_directory, workers_count)
213+
find_and_build_source_packages(output_directory, workers_count)
189214
except Ops2debError as e:
190215
error(e, exit_code)
191216

@@ -195,11 +220,11 @@ def purge(cache_directory: Path = option_cache_directory) -> None:
195220
shutil.rmtree(cache_directory, ignore_errors=True)
196221

197222

198-
@app.command(help="Look for new application releases and edit configuration file.")
223+
@app.command(help="Look for new application releases and edit configuration files.")
199224
def update(
200225
verbose: bool = option_verbose,
201226
exit_code: int = option_exit_code,
202-
search_glob: str = option_search_glob,
227+
configurations_search_pattern: str = option_configurations_search_pattern,
203228
cache_directory: Path = option_cache_directory,
204229
dry_run: bool = typer.Option(
205230
False, "--dry-run", "-d", help="Don't edit config file."
@@ -223,10 +248,11 @@ def update(
223248
),
224249
) -> None:
225250
try:
226-
configuration = load_configuration(search_glob)
251+
resources = load_resources(configurations_search_pattern)
252+
print_loaded_resources(resources)
227253
fetcher = Fetcher(cache_directory)
228254
updater.update(
229-
configuration,
255+
resources,
230256
fetcher,
231257
dry_run,
232258
output_path,
@@ -238,26 +264,28 @@ def update(
238264
error(e, exit_code)
239265

240266

241-
@app.command(help="Validate configuration file.")
267+
@app.command(help="Validate configuration files.")
242268
def validate(
243269
verbose: bool = option_verbose,
244270
exit_code: int = option_exit_code,
245-
search_glob: str = option_search_glob,
271+
configurations_search_pattern: str = option_configurations_search_pattern,
246272
) -> None:
247273
try:
248-
load_configuration(search_glob)
274+
load_resources(configurations_search_pattern)
249275
except Ops2debError as e:
250276
error(e, exit_code)
251277

252278

253-
@app.command(help="Format configuration file.")
279+
@app.command(help="Format configuration files.")
254280
def format(
255281
verbose: bool = option_verbose,
256282
exit_code: int = option_exit_code,
257-
search_glob: str = option_search_glob,
283+
configurations_search_pattern: str = option_configurations_search_pattern,
258284
) -> None:
259285
try:
260-
formatter.format_all(search_glob)
286+
resources = load_resources(configurations_search_pattern)
287+
print_loaded_resources(resources)
288+
format_all(resources)
261289
except Ops2debError as e:
262290
error(e, exit_code)
263291

@@ -267,24 +295,50 @@ def version() -> None:
267295
logger.info(__version__)
268296

269297

270-
@app.command(help="Update lockfile.")
298+
@app.command(help="Update lock files.")
271299
def lock(
272300
verbose: bool = option_verbose,
273301
exit_code: int = option_exit_code,
274-
search_glob: str = option_search_glob,
302+
configurations_search_pattern: str = option_configurations_search_pattern,
275303
cache_directory: Path = option_cache_directory,
276304
) -> None:
277305
try:
278306
fetcher = Fetcher(cache_directory)
279-
configuration = load_configuration(search_glob)
280-
for blueprint in configuration.blueprints:
281-
lock_file = configuration.get_blueprint_lock(blueprint)
307+
resources = load_resources(configurations_search_pattern)
308+
print_loaded_resources(resources)
309+
for blueprint in resources.blueprints:
310+
lock_file = resources.get_blueprint_lock(blueprint)
282311
for url in blueprint.render_fetch_urls():
283312
if url not in lock_file:
284313
fetcher.add_task(url, data=lock_file)
285314
for result in fetcher.run_tasks()[0]:
286315
result.task_data.add([result])
287-
configuration.save()
316+
resources.save()
317+
except Ops2debError as e:
318+
error(e, exit_code)
319+
320+
321+
@app.command(help="Output state drift configuration files and debian repository")
322+
def delta(
323+
verbose: bool = option_verbose,
324+
exit_code: int = option_exit_code,
325+
debian_repository: Optional[str] = option_debian_repository,
326+
configurations_search_pattern: str = option_configurations_search_pattern,
327+
output_as_json: bool = typer.Option(
328+
False, "--json", help="Output state delta as a JSON"
329+
),
330+
) -> None:
331+
if debian_repository is None:
332+
logger.error("Missing command line option --repository")
333+
sys.exit(exit_code)
334+
try:
335+
resources = load_resources(configurations_search_pattern)
336+
packages = list_repository_packages(debian_repository)
337+
state_delta = compute_state_delta(packages, resources.blueprints)
338+
if output_as_json:
339+
print(state_delta.json(sort_keys=True, indent=2))
340+
else:
341+
print_state_delta_as_rich_table(state_delta)
288342
except Ops2debError as e:
289343
error(e, exit_code)
290344

src/ops2deb/delta.py

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from pydantic import BaseModel
2+
3+
from ops2deb.apt import DebianRepositoryPackage
4+
from ops2deb.parser import Blueprint
5+
6+
7+
class StateDelta(BaseModel):
8+
added: list[DebianRepositoryPackage]
9+
removed: list[DebianRepositoryPackage]
10+
11+
12+
def compute_state_delta(
13+
packages: list[DebianRepositoryPackage], blueprints: list[Blueprint]
14+
) -> StateDelta:
15+
blueprint_slugs = set()
16+
for blueprint in blueprints:
17+
epoch = f"{blueprint.epoch}:" if blueprint.epoch else ""
18+
for version in blueprint.versions():
19+
for architecture in blueprint.architectures():
20+
debian_version = f"{epoch}{version}-{blueprint.revision}~ops2deb"
21+
blueprint_slugs.add(f"{blueprint.name}_{debian_version}_{architecture}")
22+
23+
package_slugs = set()
24+
for package in packages:
25+
package_slugs.add(f"{package.name}_{package.version}_{package.architecture}")
26+
27+
common_slugs = blueprint_slugs.intersection(package_slugs)
28+
new_slugs = blueprint_slugs - common_slugs
29+
deleted_slugs = package_slugs - common_slugs
30+
31+
state_delta = StateDelta.construct(added=[], removed=[])
32+
for slug in new_slugs:
33+
name, version, architecture = slug.split("_")
34+
state_delta.added.append(DebianRepositoryPackage(name, version, architecture))
35+
for slug in deleted_slugs:
36+
name, version, architecture = slug.split("_")
37+
state_delta.removed.append(DebianRepositoryPackage(name, version, architecture))
38+
39+
state_delta.added.sort()
40+
state_delta.removed.sort()
41+
42+
return state_delta

src/ops2deb/formatter.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from ops2deb.parser import (
1010
Blueprint,
1111
ConfigurationFile,
12+
Resources,
1213
get_default_lockfile_path,
13-
load_configuration,
1414
)
1515
from ops2deb.utils import PrettyYAMLDumper
1616

@@ -98,9 +98,9 @@ def format_configuration_file(configuration: ConfigurationFile) -> bool:
9898
return formatted_configuration_content != original_configuration_content
9999

100100

101-
def format_all(search_glob: str) -> None:
101+
def format_all(resources: Resources) -> None:
102102
formatted_configuration_files: list[str] = []
103-
for configuration in load_configuration(search_glob).configuration_files:
103+
for configuration in resources.configuration_files:
104104
if format_configuration_file(configuration) is True:
105105
formatted_configuration_files.append(str(configuration.path))
106106
if formatted_configuration_files:

0 commit comments

Comments
 (0)