Skip to content

Commit 60e8b85

Browse files
committed
release: v0.9.0 — shimkit db --on-host
Charter-completion: opt-out from container-first on `shimkit db`. Third and last of the deferred v0.5+ candidates from the ubuntu-migration validation report (after `shimkit cron` in v0.6 and `shimkit framework laravel` in v0.7 and `shimkit tls` in v0.8). Surface (additive — every existing flag still works): shimkit db <engine> up --on-host (MODERATE prompt) shimkit db <engine> down --on-host (MODERATE) shimkit db <engine> status --on-host shimkit db <engine> shell --on-host When `--on-host` is set, `up`/`down`/`status` route through `shimkit.core.HostService` (systemd on Linux, brew services on macOS) and manage an *already-installed* engine rather than a container. `shell` shells out to the host's CLI client (`mysql -h 127.0.0.1`, `psql -h 127.0.0.1`) — exporting `PGPASSWORD` for postgres so the existing password flow works unchanged. Available for mysql / mariadb / postgres only. mongo and phpmyadmin are intentionally container-only — mongo's host- packaging surface is messy and falls outside scope; phpmyadmin has no host install at all. Both reject `--on-host` with a clear "supported on-host engines: mysql, mariadb, postgres" message. Security shape — the audit-completion bit: shimkit DOES NOT install packages in --on-host mode. If the engine's client binary isn't on PATH, the command refuses (with EX_UNAVAILABLE for shell, EX_FAIL for up/down). This is the redesign's core safety promise — the original ubuntu scripts had five Critical security flags from install-on-host patterns (0.0.0.0 binds, deprecated apt-key, curl|sh) and --on-host explicitly avoids reproducing any of them. Users who want mysql on the host install it themselves via apt / brew / dnf; shimkit just manages it. Architecture additions: - `shimkit.core.HostService` — new cross-platform service- manager facade. `HostService.detect(platform)` returns `SystemdHost()` on Linux, `BrewServicesHost()` on macOS, `None` elsewhere. Each subclass implements `state()` / `start()` / `stop()` returning a typed `HostServiceResult`. Built on top of the existing `Systemd` facade. - `tools.db.host_services` config block — per-engine service- name mapping. mysql: `mysql` (both), mariadb: `mariadb` (both), postgres: `postgresql` on Linux / `postgresql@16` on macOS. Override in user config when your distro or homebrew formula diverges. - `Engine.supports_on_host()` / `Engine.host_shell_argv()` / `Engine.host_client_binary()` — three new methods on the engine ABC. mysql / mariadb / postgres override `supports_on_host=True` and provide `-h 127.0.0.1 -u… -p…` argv; mongo / phpmyadmin keep the default False. - `DbManager.boot(on_host=True)` skips the docker preflight entirely — useful for users with a host install who never installed Docker. Container-mode methods now assert `self._env is not None` at entry so a stray container call on an on-host manager fails loudly rather than `AttributeError` on None. Tests: 21 new in tests/test_tools_db_on_host.py (609 → 630 total). Boot semantics (on_host skips docker preflight; default still runs it), refusals (mongo / phpmyadmin / missing-binary / unsupported-platform), up / down / status happy paths with both Linux and macOS service-name resolution (mysql → mysql, postgres → postgresql vs postgresql@16), dry-run no-op, failed start propagates exit 1, shell routes through CommandRunner with PGPASSWORD-passthrough for postgres, engine driver layer correctness (supports_on_host table; host_shell_argv targets 127.0.0.1; postgres uses psql as client). Gates: pytest 630 passed, ruff clean, mypy strict clean. No new optional dependency extras.
1 parent 44d1d15 commit 60e8b85

15 files changed

Lines changed: 909 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,68 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
66

77
## [Unreleased]
88

9+
## [0.9.0] — 2026-05-15
10+
11+
### Added
12+
13+
- `shimkit db <engine> ... --on-host` — opt-out from container-
14+
first. When `--on-host` is passed, `up` / `down` / `status` /
15+
`shell` route through `HostService` (systemd on Linux, `brew
16+
services` on macOS) and manage an already-installed host
17+
engine rather than a container. Available for mysql / mariadb
18+
/ postgres; mongo and phpmyadmin are intentionally
19+
container-only (mongo's host-packaging surface is messy;
20+
phpmyadmin has no host install).
21+
22+
**shimkit does NOT install packages in --on-host mode** — if
23+
the engine's client (`mysql`/`mariadb`/`psql`) isn't on PATH,
24+
the command refuses with a clear remediation. This is the
25+
redesign's core safety promise: the original ubuntu scripts
26+
had five Critical security flags from install-on-host
27+
patterns (0.0.0.0 binds, deprecated apt-key, curl|sh) and
28+
shimkit's `--on-host` mode explicitly avoids reproducing
29+
them.
30+
31+
- `shimkit.core.HostService` — new abstraction with
32+
`SystemdHost` (Linux) and `BrewServicesHost` (macOS) concrete
33+
backends. `HostService.detect(platform)` returns the right
34+
one or `None` for unsupported systems. Exposes `state()` /
35+
`start()` / `stop()` returning a typed `HostServiceResult`.
36+
Pairs with the existing `Systemd` facade — the new class is
37+
the cross-platform layer above it.
38+
39+
- `tools.db.host_services` config block — per-engine service-
40+
name mapping for Linux + macOS. Defaults: `mysql`
41+
`mysql` (both), `mariadb``mariadb` (both), `postgres`
42+
`postgresql` on Linux / `postgresql@16` on macOS. Override
43+
per-install in user config when your distro or homebrew
44+
formula diverges.
45+
46+
- `Engine.supports_on_host()` / `Engine.host_shell_argv()` /
47+
`Engine.host_client_binary()` — three new methods on the
48+
engine ABC. Mysql / mariadb / postgres override
49+
`supports_on_host=True` and provide host-side argv targeting
50+
`127.0.0.1`; mongo / phpmyadmin keep the default `False`.
51+
52+
### Changed
53+
54+
- `DbManager.boot(on_host=True)` — skips the docker preflight
55+
entirely when the caller is using `--on-host`. Container-mode
56+
methods now assert `self._env is not None` at entry; this
57+
prevents a stray `up()` call on an on-host manager from
58+
blowing up with `AttributeError` on a None DockerEnv.
59+
60+
### Tests
61+
62+
- 21 new tests in `tests/test_tools_db_on_host.py` (609 → 630
63+
total). Boot semantics (on_host skips docker preflight;
64+
default still runs it), refusals (mongo / phpmyadmin /
65+
missing-binary / unsupported-platform), up / down / status
66+
happy paths on both Linux and macOS service-name resolution,
67+
dry-run no-op, failed start propagates exit 1, shell
68+
routes through CommandRunner with PGPASSWORD for postgres,
69+
engine driver layer correctness.
70+
971
## [0.8.0] — 2026-05-15
1072

1173
### Added

docs/tools/db.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,42 @@ Universal flags (before the subcommand): `--quiet`, `--verbose`,
2828
`--log-file PATH`, `--no-color`, `--color`, `--no-input`.
2929
Per-command flags (after the subcommand): `--json`, `--dry-run`,
3030
`--yes`, `--force`, `--name <id>`, `--port N`, `--bind HOST`,
31-
`--volume PATH`, `--ephemeral`, `--password PWD`, `--confirm TOKEN`.
31+
`--volume PATH`, `--ephemeral`, `--password PWD`, `--confirm TOKEN`,
32+
`--on-host` (see below).
33+
34+
## --on-host (opt-out from container-first)
35+
36+
The default path is containers — that's how shimkit dissolves
37+
the security flags from the original ubuntu provisioning scripts
38+
(0.0.0.0 binds, deprecated apt-key, etc). If you've installed
39+
mysql / mariadb / postgres on the host yourself (via apt, brew,
40+
dnf), pass `--on-host` to manage *that* engine rather than a
41+
container.
42+
43+
| `--on-host` command | What it does |
44+
|---------------------|--------------|
45+
| `shimkit db <engine> up --on-host` | `systemctl start <service>` (Linux) / `brew services start <name>` (macOS). |
46+
| `shimkit db <engine> down --on-host` | `systemctl stop <service>` / `brew services stop <name>`. |
47+
| `shimkit db <engine> status --on-host` | Reports `running` / `stopped` / `missing`. |
48+
| `shimkit db <engine> shell --on-host` | `mysql -h 127.0.0.1 -uroot -p…` (or `psql`) directly against the host install. |
49+
50+
Limits:
51+
52+
- **mysql / mariadb / postgres only.** `mongo` and `phpmyadmin`
53+
reject `--on-host` (mongo's host packaging surface is messy
54+
and out of scope; phpmyadmin has no host install). Use the
55+
container path for those.
56+
- **`shimkit` never installs the package.** If `mysql` (or
57+
`mariadb`, `psql`) isn't on PATH, the command refuses — install
58+
via your package manager first. This is deliberate: the
59+
install-on-host scripts in the original ubuntu source had
60+
five Critical security flags (0.0.0.0 binds, deprecated
61+
apt-key, curl|sh), and shimkit's redesign explicitly avoids
62+
reproducing them.
63+
- Service names live in config (`tools.db.host_services.<engine>.{service_linux,service_macos}`).
64+
Override per-install if your distro or homebrew formula
65+
diverges from the defaults (e.g. `postgresql@16` on macOS,
66+
`postgresql` on Debian).
3267

3368
## Engines
3469

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "shimkit"
7-
version = "0.8.0"
7+
version = "0.9.0"
88
description = "A toolkit of developer utilities — Java version manager, shell upgrader, and more. Python tools, shimmed by bash."
99
readme = "README.md"
1010
license = { file = "LICENSE" }

src/shimkit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
Python tools, shimmed by bash.
44
"""
55

6-
__version__ = "0.8.0"
6+
__version__ = "0.9.0"
77
__all__ = ["__version__"]

src/shimkit/config/defaults.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@
157157
"postgres": {"image": "postgres:16", "default_port": 15432},
158158
"mongo": {"image": "mongo:7", "default_port": 17017},
159159
"phpmyadmin": {"image": "phpmyadmin:5", "default_port": 18080}
160+
},
161+
"host_services": {
162+
"mysql": {"service_linux": "mysql", "service_macos": "mysql"},
163+
"mariadb": {"service_linux": "mariadb", "service_macos": "mariadb"},
164+
"postgres": {"service_linux": "postgresql", "service_macos": "postgresql@16"}
160165
}
161166
},
162167
"stack": {

src/shimkit/config/schema.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,18 @@ class DbEngineEntry(_StrictModel):
275275
default_port: int = Field(ge=1, le=65535)
276276

277277

278+
class DbHostServiceEntry(_StrictModel):
279+
"""Per-engine service-name mapping for `--on-host` mode.
280+
281+
Linux assumes systemd unit names; macOS assumes the literal
282+
`brew services` formula name. Override per-install when your
283+
distro or formula uses a different name.
284+
"""
285+
286+
service_linux: str
287+
service_macos: str
288+
289+
278290
class DbConfig(_StrictModel):
279291
"""`shimkit db` — container-first database orchestration."""
280292

@@ -295,6 +307,20 @@ class DbConfig(_StrictModel):
295307
"phpmyadmin": DbEngineEntry(image="phpmyadmin:5", default_port=18080),
296308
}
297309
)
310+
# Per-engine service names used when --on-host is passed. Engines
311+
# absent from this map cannot be managed --on-host (mongo and
312+
# phpmyadmin are intentionally absent — phpmyadmin has no host
313+
# install, mongo's host packaging surface is messy and falls
314+
# outside shimkit's `db --on-host` scope).
315+
host_services: dict[str, DbHostServiceEntry] = Field(
316+
default_factory=lambda: {
317+
"mysql": DbHostServiceEntry(service_linux="mysql", service_macos="mysql"),
318+
"mariadb": DbHostServiceEntry(service_linux="mariadb", service_macos="mariadb"),
319+
"postgres": DbHostServiceEntry(
320+
service_linux="postgresql", service_macos="postgresql@16"
321+
),
322+
}
323+
)
298324

299325

300326
class VersionConstraint(_StrictModel):

src/shimkit/core/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010

1111
from .command import CommandResult, CommandRunner, has_sudo_cached, is_root, sudo_prefix
1212
from .docker import DockerEnv, DockerNotAvailableError, ExecOutcome
13+
from .host_service import (
14+
BrewServicesHost,
15+
HostService,
16+
HostServiceResult,
17+
ServiceState,
18+
SystemdHost,
19+
)
1320
from .json_event import Event, emit_json
1421
from .log import attach_file_handler, get_logger, set_verbose
1522
from .menu import AskResult, FallbackMenu, Menu
@@ -30,6 +37,7 @@
3037
__all__ = [
3138
"UI",
3239
"AskResult",
40+
"BrewServicesHost",
3341
"CommandResult",
3442
"CommandRunner",
3543
"Detector",
@@ -38,14 +46,18 @@
3846
"Event",
3947
"ExecOutcome",
4048
"FallbackMenu",
49+
"HostService",
50+
"HostServiceResult",
4151
"Menu",
4252
"PackageManager",
4353
"Platform",
4454
"Result",
55+
"ServiceState",
4556
"Shell",
4657
"ShellConfigWriter",
4758
"Status",
4859
"Systemd",
60+
"SystemdHost",
4961
"ToolVersion",
5062
"UnitState",
5163
"VersionConstraint",

src/shimkit/core/host_service.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Cross-platform service-manager facade.
2+
3+
Linux: shells out via the existing :class:`Systemd`.
4+
macOS: shells out via `brew services`.
5+
6+
Used by tools that need to talk to host-installed daemons rather
7+
than container ones — `shimkit db --on-host` is the first
8+
consumer.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from dataclasses import dataclass
14+
from typing import Literal
15+
16+
from .command import CommandResult, CommandRunner
17+
from .platform import Platform
18+
from .systemd import Systemd
19+
20+
ServiceState = Literal["running", "stopped", "missing"]
21+
22+
23+
@dataclass(frozen=True)
24+
class HostServiceResult:
25+
"""Combined CommandResult-ish + state, returned by start/stop."""
26+
27+
ok: bool
28+
state: ServiceState
29+
stdout: str = ""
30+
stderr: str = ""
31+
32+
33+
class HostService:
34+
"""Abstract interface implemented by SystemdHost / BrewServicesHost.
35+
36+
Tests mock the concrete subclasses directly. Producers ask for an
37+
implementation via :meth:`detect`; ``None`` means the host has no
38+
supported service manager.
39+
"""
40+
41+
@classmethod
42+
def detect(cls, platform: Platform | None = None) -> HostService | None:
43+
plat = platform or Platform.detect()
44+
if plat.is_linux:
45+
return SystemdHost()
46+
if plat.is_macos:
47+
return BrewServicesHost()
48+
return None
49+
50+
def state(self, service: str) -> ServiceState:
51+
raise NotImplementedError
52+
53+
def start(self, service: str) -> HostServiceResult:
54+
raise NotImplementedError
55+
56+
def stop(self, service: str) -> HostServiceResult:
57+
raise NotImplementedError
58+
59+
60+
class SystemdHost(HostService):
61+
"""Linux: shells out via :class:`Systemd`."""
62+
63+
def state(self, service: str) -> ServiceState:
64+
unit = Systemd.state(service if service.endswith(".service") else f"{service}.service")
65+
if not unit.exists:
66+
return "missing"
67+
return "running" if unit.active else "stopped"
68+
69+
def start(self, service: str) -> HostServiceResult:
70+
r = Systemd.start(service)
71+
return _wrap(r, self.state(service))
72+
73+
def stop(self, service: str) -> HostServiceResult:
74+
r = Systemd.stop(service)
75+
return _wrap(r, self.state(service))
76+
77+
78+
class BrewServicesHost(HostService):
79+
"""macOS: shells out via `brew services <action> <name>`."""
80+
81+
def state(self, service: str) -> ServiceState:
82+
# `brew services list` output (whitespace columns):
83+
# Name Status User File
84+
# mysql started you ~/...
85+
# postgresql@16 none ...
86+
r = CommandRunner.run(["brew", "services", "list"])
87+
if not r.ok:
88+
return "missing"
89+
for line in r.stdout.splitlines():
90+
parts = line.split()
91+
if not parts or parts[0] != service:
92+
continue
93+
status = parts[1] if len(parts) > 1 else "none"
94+
if status in {"started", "scheduled"}:
95+
return "running"
96+
return "stopped"
97+
return "missing"
98+
99+
def start(self, service: str) -> HostServiceResult:
100+
r = CommandRunner.run(["brew", "services", "start", service])
101+
return _wrap(r, self.state(service))
102+
103+
def stop(self, service: str) -> HostServiceResult:
104+
r = CommandRunner.run(["brew", "services", "stop", service])
105+
return _wrap(r, self.state(service))
106+
107+
108+
def _wrap(r: CommandResult, state: ServiceState) -> HostServiceResult:
109+
return HostServiceResult(ok=r.ok, state=state, stdout=r.stdout, stderr=r.stderr)

0 commit comments

Comments
 (0)