offbox 0.1.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.
- offbox/__init__.py +2 -0
- offbox/__main__.py +4 -0
- offbox/_data/host/cloud-init.yaml.j2 +15 -0
- offbox/_data/host/host-init.sh +230 -0
- offbox/_data/images/universal/Dockerfile +101 -0
- offbox/_data/images/universal/offbox-init +75 -0
- offbox/_data/images/universal/offbox-run +131 -0
- offbox/_data/providers/gcp/.terraform.lock.hcl +52 -0
- offbox/_data/providers/gcp/main.tf +67 -0
- offbox/_data/providers/gcp/outputs.tf +7 -0
- offbox/_data/providers/gcp/provider.toml +9 -0
- offbox/_data/providers/gcp/variables.tf +33 -0
- offbox/_data/providers/gcp/versions.tf +12 -0
- offbox/_data/providers/hetzner/.terraform.lock.hcl +51 -0
- offbox/_data/providers/hetzner/main.tf +32 -0
- offbox/_data/providers/hetzner/outputs.tf +7 -0
- offbox/_data/providers/hetzner/provider.toml +9 -0
- offbox/_data/providers/hetzner/variables.tf +28 -0
- offbox/_data/providers/hetzner/versions.tf +12 -0
- offbox/_data/providers/ssh/provider.toml +4 -0
- offbox/cli/__init__.py +117 -0
- offbox/cli/config.py +39 -0
- offbox/cli/env.py +109 -0
- offbox/cli/exec.py +50 -0
- offbox/cli/host.py +313 -0
- offbox/cli/misc.py +132 -0
- offbox/cli/ports.py +44 -0
- offbox/config.py +189 -0
- offbox/console.py +63 -0
- offbox/doctor.py +174 -0
- offbox/drivers/__init__.py +0 -0
- offbox/drivers/base.py +40 -0
- offbox/drivers/none.py +114 -0
- offbox/drivers/tofu.py +59 -0
- offbox/errors.py +45 -0
- offbox/execute.py +772 -0
- offbox/host/__init__.py +89 -0
- offbox/host/activity.py +58 -0
- offbox/host/auto.py +322 -0
- offbox/host/cloudinit.py +44 -0
- offbox/host/create.py +198 -0
- offbox/host/destroy.py +151 -0
- offbox/host/init.py +196 -0
- offbox/host/ready.py +106 -0
- offbox/host/timer.py +227 -0
- offbox/host/usage.py +123 -0
- offbox/host/warnings.py +61 -0
- offbox/locking.py +27 -0
- offbox/logging.py +154 -0
- offbox/logs.py +135 -0
- offbox/naming.py +67 -0
- offbox/paths.py +55 -0
- offbox/ports.py +610 -0
- offbox/project.py +187 -0
- offbox/providers.py +117 -0
- offbox/py.typed +0 -0
- offbox/runner.py +291 -0
- offbox/runtime.py +887 -0
- offbox/setup.py +181 -0
- offbox/share.py +286 -0
- offbox/ssh.py +179 -0
- offbox/state.py +157 -0
- offbox/status.py +378 -0
- offbox/sync.py +380 -0
- offbox/timings.py +69 -0
- offbox/tools.py +227 -0
- offbox/uninstall.py +114 -0
- offbox-0.1.0.dist-info/METADATA +140 -0
- offbox-0.1.0.dist-info/RECORD +72 -0
- offbox-0.1.0.dist-info/WHEEL +4 -0
- offbox-0.1.0.dist-info/entry_points.txt +2 -0
- offbox-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
locals {
|
|
2
|
+
region = replace(var.region, "/-[a-z]$/", "")
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
resource "google_compute_network" "this" {
|
|
6
|
+
name = var.name
|
|
7
|
+
description = "Offbox host ${var.name}"
|
|
8
|
+
auto_create_subnetworks = false
|
|
9
|
+
routing_mode = "REGIONAL"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
resource "google_compute_subnetwork" "this" {
|
|
13
|
+
name = var.name
|
|
14
|
+
region = local.region
|
|
15
|
+
network = google_compute_network.this.id
|
|
16
|
+
ip_cidr_range = "10.90.0.0/24"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resource "google_compute_firewall" "this" {
|
|
20
|
+
name = "${var.name}-ssh"
|
|
21
|
+
description = "Offbox SSH and ICMP for ${var.name}"
|
|
22
|
+
network = google_compute_network.this.name
|
|
23
|
+
direction = "INGRESS"
|
|
24
|
+
source_ranges = ["0.0.0.0/0"]
|
|
25
|
+
target_tags = [var.name]
|
|
26
|
+
|
|
27
|
+
allow {
|
|
28
|
+
protocol = "tcp"
|
|
29
|
+
ports = ["22"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
allow {
|
|
33
|
+
protocol = "icmp"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
resource "google_compute_instance" "this" {
|
|
38
|
+
name = var.name
|
|
39
|
+
machine_type = var.size
|
|
40
|
+
zone = var.region
|
|
41
|
+
labels = var.labels
|
|
42
|
+
tags = [var.name]
|
|
43
|
+
deletion_protection = false
|
|
44
|
+
|
|
45
|
+
boot_disk {
|
|
46
|
+
auto_delete = true
|
|
47
|
+
|
|
48
|
+
initialize_params {
|
|
49
|
+
image = var.image
|
|
50
|
+
size = 50
|
|
51
|
+
type = "pd-balanced"
|
|
52
|
+
labels = var.labels
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
network_interface {
|
|
57
|
+
subnetwork = google_compute_subnetwork.this.id
|
|
58
|
+
|
|
59
|
+
access_config {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
metadata = {
|
|
63
|
+
user-data = var.user_data
|
|
64
|
+
enable-oslogin = "FALSE"
|
|
65
|
+
block-project-ssh-keys = "TRUE"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
variable "name" {
|
|
2
|
+
type = string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
variable "size" {
|
|
6
|
+
type = string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
variable "region" {
|
|
10
|
+
type = string
|
|
11
|
+
|
|
12
|
+
validation {
|
|
13
|
+
condition = can(regex("^[a-z]+-[a-z]+[0-9]+-[a-z]$", var.region))
|
|
14
|
+
error_message = "The GCP region setting must be a zone, for example us-east4-c."
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
variable "image" {
|
|
19
|
+
type = string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
variable "ssh_public_key" {
|
|
23
|
+
type = string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
variable "user_data" {
|
|
27
|
+
type = string
|
|
28
|
+
sensitive = true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
variable "labels" {
|
|
32
|
+
type = map(string)
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Provider checksums for Terraform (registry.terraform.io) and OpenTofu (registry.opentofu.org).
|
|
2
|
+
# Each tool verifies its own registry entry; init drops the other entry from the host workspace copy.
|
|
3
|
+
# Regenerate both with "providers lock" for linux_amd64, linux_arm64, darwin_amd64 and darwin_arm64.
|
|
4
|
+
|
|
5
|
+
provider "registry.terraform.io/hetznercloud/hcloud" {
|
|
6
|
+
version = "1.68.0"
|
|
7
|
+
constraints = ">= 1.68.0, < 1.69.0"
|
|
8
|
+
hashes = [
|
|
9
|
+
"h1:2MU8gDe67KjqALpvzzRfWONH2c/8djL+yxTnym9/FjY=",
|
|
10
|
+
"h1:BrgHGuh9WEMd8CQefgdSqhcePf4CZPdovwlHNdo3h70=",
|
|
11
|
+
"h1:KOFp1JbzZ6Xj2K80QL7HGJM6oG+oEo7tx3lIx3d5POM=",
|
|
12
|
+
"h1:oxpWvl0qfZplr759glEx537EqkkAgtagBtNsI8V13H8=",
|
|
13
|
+
"zh:0c3c2eb5bad6e2853da46dd83ed9c93c547b79f2abe977d492da9cf9d7236a62",
|
|
14
|
+
"zh:0ccae6bb2daee7c30b55672645b31c50920839a8aadac502d187f105352c1b37",
|
|
15
|
+
"zh:11559d821030e5f4657b81153419f3c5e4f6389e8cc6f8086db2ebeb7aade6bc",
|
|
16
|
+
"zh:164012cca257951da2870e9a0a0eb0be2ebde26fd27e30081c8d7e8729467a6e",
|
|
17
|
+
"zh:1fc7fea548dc8a0211ee832b0f1be489301f431cdfb153effd2fda7f63c02073",
|
|
18
|
+
"zh:73855025066ec55230559296b5c92d441b1b6dc2ce27a1102ab1e17f60cdcf0e",
|
|
19
|
+
"zh:78bfcca5d613f3285c2e326e92865d08e24d0574e29201ce9c1df612aa912a10",
|
|
20
|
+
"zh:84d6972ef2d6e03dc416ab34c8e266e0958ff390c7ad87761180706658b31614",
|
|
21
|
+
"zh:a04f8e12811e000657151cd6eed5eb80ae1042b8123bfea28343147ceda40f96",
|
|
22
|
+
"zh:b825f6500592d30004b6de8547ca1fa7841e16da0571cee8a67782cb84ec65cd",
|
|
23
|
+
"zh:d6bc5f6f27453248889e5df967111a187405f1434175a90221853226d47484af",
|
|
24
|
+
"zh:e052ad7a40cf1f63588c2b435ce7a9baca5ef0e1f9dcb321a06756eb3d38751a",
|
|
25
|
+
"zh:ea1497399246a51f16e3ff4a13dfba4c95a49997c2da7850703a086e211b7435",
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
provider "registry.opentofu.org/hetznercloud/hcloud" {
|
|
30
|
+
version = "1.68.0"
|
|
31
|
+
constraints = ">= 1.68.0, < 1.69.0"
|
|
32
|
+
hashes = [
|
|
33
|
+
"h1:2MU8gDe67KjqALpvzzRfWONH2c/8djL+yxTnym9/FjY=",
|
|
34
|
+
"h1:BrgHGuh9WEMd8CQefgdSqhcePf4CZPdovwlHNdo3h70=",
|
|
35
|
+
"h1:KOFp1JbzZ6Xj2K80QL7HGJM6oG+oEo7tx3lIx3d5POM=",
|
|
36
|
+
"h1:oxpWvl0qfZplr759glEx537EqkkAgtagBtNsI8V13H8=",
|
|
37
|
+
"zh:0c3c2eb5bad6e2853da46dd83ed9c93c547b79f2abe977d492da9cf9d7236a62",
|
|
38
|
+
"zh:0ccae6bb2daee7c30b55672645b31c50920839a8aadac502d187f105352c1b37",
|
|
39
|
+
"zh:11559d821030e5f4657b81153419f3c5e4f6389e8cc6f8086db2ebeb7aade6bc",
|
|
40
|
+
"zh:164012cca257951da2870e9a0a0eb0be2ebde26fd27e30081c8d7e8729467a6e",
|
|
41
|
+
"zh:1fc7fea548dc8a0211ee832b0f1be489301f431cdfb153effd2fda7f63c02073",
|
|
42
|
+
"zh:73855025066ec55230559296b5c92d441b1b6dc2ce27a1102ab1e17f60cdcf0e",
|
|
43
|
+
"zh:78bfcca5d613f3285c2e326e92865d08e24d0574e29201ce9c1df612aa912a10",
|
|
44
|
+
"zh:84d6972ef2d6e03dc416ab34c8e266e0958ff390c7ad87761180706658b31614",
|
|
45
|
+
"zh:a04f8e12811e000657151cd6eed5eb80ae1042b8123bfea28343147ceda40f96",
|
|
46
|
+
"zh:b825f6500592d30004b6de8547ca1fa7841e16da0571cee8a67782cb84ec65cd",
|
|
47
|
+
"zh:d6bc5f6f27453248889e5df967111a187405f1434175a90221853226d47484af",
|
|
48
|
+
"zh:e052ad7a40cf1f63588c2b435ce7a9baca5ef0e1f9dcb321a06756eb3d38751a",
|
|
49
|
+
"zh:ea1497399246a51f16e3ff4a13dfba4c95a49997c2da7850703a086e211b7435",
|
|
50
|
+
]
|
|
51
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
resource "hcloud_firewall" "this" {
|
|
2
|
+
name = var.name
|
|
3
|
+
labels = var.labels
|
|
4
|
+
|
|
5
|
+
rule {
|
|
6
|
+
direction = "in"
|
|
7
|
+
protocol = "tcp"
|
|
8
|
+
port = "22"
|
|
9
|
+
source_ips = ["0.0.0.0/0", "::/0"]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
rule {
|
|
13
|
+
direction = "in"
|
|
14
|
+
protocol = "icmp"
|
|
15
|
+
source_ips = ["0.0.0.0/0", "::/0"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resource "hcloud_server" "this" {
|
|
20
|
+
name = var.name
|
|
21
|
+
image = var.image
|
|
22
|
+
server_type = var.size
|
|
23
|
+
location = var.region
|
|
24
|
+
user_data = var.user_data
|
|
25
|
+
labels = var.labels
|
|
26
|
+
firewall_ids = [hcloud_firewall.this.id]
|
|
27
|
+
|
|
28
|
+
public_net {
|
|
29
|
+
ipv4_enabled = true
|
|
30
|
+
ipv6_enabled = true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
variable "name" {
|
|
2
|
+
type = string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
variable "size" {
|
|
6
|
+
type = string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
variable "region" {
|
|
10
|
+
type = string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
variable "image" {
|
|
14
|
+
type = string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
variable "ssh_public_key" {
|
|
18
|
+
type = string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
variable "user_data" {
|
|
22
|
+
type = string
|
|
23
|
+
sensitive = true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
variable "labels" {
|
|
27
|
+
type = map(string)
|
|
28
|
+
}
|
offbox/cli/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from typer._click import ClickException, Context
|
|
6
|
+
from typer.core import TyperGroup
|
|
7
|
+
from typer.exceptions import Abort, Exit
|
|
8
|
+
|
|
9
|
+
from offbox import __version__, console
|
|
10
|
+
from offbox.cli.config import register as register_config_command
|
|
11
|
+
from offbox.cli.env import register as register_env_commands
|
|
12
|
+
from offbox.cli.exec import register as register_exec_command
|
|
13
|
+
from offbox.cli.host import app as host_app
|
|
14
|
+
from offbox.cli.misc import register as register_misc_commands
|
|
15
|
+
from offbox.cli.ports import register as register_ports_command
|
|
16
|
+
from offbox.doctor import diagnose
|
|
17
|
+
from offbox.errors import OffboxError
|
|
18
|
+
from offbox.logging import configure, redact, register_exec_secrets
|
|
19
|
+
from offbox.paths import Paths
|
|
20
|
+
from offbox.runner import SubprocessRunner
|
|
21
|
+
from offbox.timings import collect
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ErrorGroup(TyperGroup):
|
|
27
|
+
def make_context(self, info_name: str | None, args: list[str], parent: Context | None = None, **extra: object) -> Context:
|
|
28
|
+
paths = Paths.discover()
|
|
29
|
+
log_path = paths.log_file()
|
|
30
|
+
try:
|
|
31
|
+
register_exec_secrets(args)
|
|
32
|
+
paths.secure_roots()
|
|
33
|
+
configure(paths, [info_name or "offbox", *args])
|
|
34
|
+
return super().make_context(info_name, args, parent, **extra)
|
|
35
|
+
except ClickException as exc:
|
|
36
|
+
console.error(exc.format_message(), "Run offbox --help for valid arguments.")
|
|
37
|
+
raise Exit(exc.exit_code) from exc
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
console.error(f"Cannot initialize log file {log_path}", "Check XDG_STATE_HOME permissions and available disk space.")
|
|
40
|
+
raise Exit(1) from exc
|
|
41
|
+
|
|
42
|
+
def invoke(self, ctx: Context) -> object:
|
|
43
|
+
with collect(ctx.params.get("timings") is True):
|
|
44
|
+
return self._invoke_with_errors(ctx)
|
|
45
|
+
|
|
46
|
+
def _invoke_with_errors(self, ctx: Context) -> object:
|
|
47
|
+
log_path = Paths.discover().log_file()
|
|
48
|
+
try:
|
|
49
|
+
return super().invoke(ctx)
|
|
50
|
+
except OffboxError as exc:
|
|
51
|
+
logger.exception("offbox failure")
|
|
52
|
+
console.error(redact(exc.message), redact(exc.fix) if exc.fix else "Check the log and retry.")
|
|
53
|
+
raise Exit(exc.exit_code) from exc
|
|
54
|
+
except ClickException as exc:
|
|
55
|
+
console.error(exc.format_message(), "Run offbox --help for valid arguments.")
|
|
56
|
+
raise Exit(exc.exit_code) from exc
|
|
57
|
+
except (Exit, Abort):
|
|
58
|
+
raise
|
|
59
|
+
except KeyboardInterrupt as exc:
|
|
60
|
+
logger.warning("offbox interrupted", exc_info=True)
|
|
61
|
+
console.warn("Interrupted")
|
|
62
|
+
raise Exit(130) from exc
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
logger.exception("unexpected failure")
|
|
65
|
+
console.error(f"unexpected error, see {log_path}", "Check the log and report this error.")
|
|
66
|
+
raise Exit(1) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, cls=ErrorGroup)
|
|
70
|
+
app.add_typer(host_app, name="host")
|
|
71
|
+
register_env_commands(app)
|
|
72
|
+
register_exec_command(app)
|
|
73
|
+
register_ports_command(app)
|
|
74
|
+
register_misc_commands(app)
|
|
75
|
+
register_config_command(app)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _version_callback(value: bool) -> None:
|
|
79
|
+
if value:
|
|
80
|
+
console.version(__version__)
|
|
81
|
+
raise typer.Exit()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.callback()
|
|
85
|
+
def main(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
version: Annotated[
|
|
88
|
+
bool, typer.Option("--version", callback=_version_callback, is_eager=True, help="Show the version and exit.")
|
|
89
|
+
] = False,
|
|
90
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Echo commands with secrets redacted.")] = False,
|
|
91
|
+
timings: Annotated[bool, typer.Option("--timings", help="Show stage durations on stderr; excludes CLI startup.")] = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
ctx.obj = SubprocessRunner(verbose=verbose)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def version() -> None:
|
|
98
|
+
"""Show the version and exit."""
|
|
99
|
+
console.version(__version__)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def doctor(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
|
|
104
|
+
"""Check local tools, configuration and default host connectivity."""
|
|
105
|
+
runner = ctx.obj
|
|
106
|
+
if not isinstance(runner, SubprocessRunner):
|
|
107
|
+
raise OffboxError("Runner is unavailable", "Restart offbox.")
|
|
108
|
+
report = diagnose(Paths.discover(), runner)
|
|
109
|
+
if json:
|
|
110
|
+
console.json_out(report)
|
|
111
|
+
else:
|
|
112
|
+
for check in report.checks:
|
|
113
|
+
if check.ok:
|
|
114
|
+
console.success(check.message)
|
|
115
|
+
else:
|
|
116
|
+
console.error(check.message, check.fix)
|
|
117
|
+
raise typer.Exit(report.exit_code)
|
offbox/cli/config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json as jsonlib
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from offbox import console
|
|
7
|
+
from offbox.errors import OffboxError
|
|
8
|
+
from offbox.naming import find_worktree
|
|
9
|
+
from offbox.paths import Paths
|
|
10
|
+
from offbox.project import report
|
|
11
|
+
from offbox.runner import SubprocessRunner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
|
|
15
|
+
"""Show this worktree's effective settings, where each comes from and its OFFBOX_* variable."""
|
|
16
|
+
runner: object = ctx.find_root().obj
|
|
17
|
+
if not isinstance(runner, SubprocessRunner):
|
|
18
|
+
raise OffboxError("Runner is unavailable", "Restart offbox.")
|
|
19
|
+
paths = Paths.discover()
|
|
20
|
+
result = report(paths, find_worktree(runner).root)
|
|
21
|
+
if json:
|
|
22
|
+
console.json_out(result)
|
|
23
|
+
return
|
|
24
|
+
files = result.files
|
|
25
|
+
console.output("Sources, later wins: default < user < project < local < env")
|
|
26
|
+
for name, path, suffix in (("user", files.user, " [project]"), ("project", files.project, ""), ("local", files.local, "")):
|
|
27
|
+
console.output(f" {name:<8} {path}{suffix}{'' if path.is_file() else ' (not present)'}")
|
|
28
|
+
console.output("")
|
|
29
|
+
rows = [
|
|
30
|
+
(setting.key, "unset" if setting.value is None else jsonlib.dumps(setting.value), setting.source, setting.env)
|
|
31
|
+
for setting in result.settings
|
|
32
|
+
]
|
|
33
|
+
widths = [max(len(row[column]) for row in rows) for column in range(3)]
|
|
34
|
+
for key, value, source, env in rows:
|
|
35
|
+
console.output(f"{key:<{widths[0]}} {value:<{widths[1]}} {source:<{widths[2]}} {env}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register(app: typer.Typer) -> None:
|
|
39
|
+
app.command()(config)
|
offbox/cli/env.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from offbox import console
|
|
7
|
+
from offbox.errors import OffboxError, PreconditionError
|
|
8
|
+
from offbox.paths import Paths
|
|
9
|
+
from offbox.runner import SubprocessRunner
|
|
10
|
+
from offbox.runtime import Runtime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _runtime(ctx: typer.Context) -> Runtime:
|
|
14
|
+
value: object = ctx.find_root().obj
|
|
15
|
+
if not isinstance(value, SubprocessRunner):
|
|
16
|
+
raise OffboxError("Runner is unavailable", "Restart offbox.")
|
|
17
|
+
return Runtime(Paths.discover(), value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _confirm(message: str, yes: bool, fix: str) -> None:
|
|
21
|
+
if yes:
|
|
22
|
+
return
|
|
23
|
+
if not sys.stdin.isatty():
|
|
24
|
+
raise PreconditionError("This operation requires --yes without a TTY", fix)
|
|
25
|
+
if not typer.confirm(message):
|
|
26
|
+
raise PreconditionError("Operation cancelled", "Run the command again when ready.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def up(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
host: Annotated[str | None, typer.Option("--host", help="Host for a new environment. Default: the default host.")] = None,
|
|
32
|
+
fresh: Annotated[
|
|
33
|
+
bool, typer.Option("--fresh", help="Recreate the container and delete its inner Docker data; source files are kept.")
|
|
34
|
+
] = False,
|
|
35
|
+
no_stack: Annotated[bool, typer.Option("--no-stack", help="Skip starting the project's Compose stack.")] = False,
|
|
36
|
+
yes: Annotated[bool, typer.Option("--yes", help="Confirm --fresh without a prompt.")] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create or start this worktree's isolated environment."""
|
|
39
|
+
if fresh:
|
|
40
|
+
_confirm(
|
|
41
|
+
"Recreate this environment and delete its inner Docker data (images, volumes, containers)?",
|
|
42
|
+
yes,
|
|
43
|
+
"Run offbox up --fresh --yes.",
|
|
44
|
+
)
|
|
45
|
+
_runtime(ctx).up(host_name=host, fresh=fresh, no_stack=no_stack)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stop(ctx: typer.Context) -> None:
|
|
49
|
+
"""Stop this environment while retaining its data."""
|
|
50
|
+
_runtime(ctx).stop()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rm(ctx: typer.Context, yes: Annotated[bool, typer.Option("--yes", help="Confirm without a prompt.")] = False) -> None:
|
|
54
|
+
"""Remove this worktree's remote environment and Docker data."""
|
|
55
|
+
_confirm("Remove this worktree's remote environment and all its Docker data?", yes, "Run offbox rm --yes to confirm removal.")
|
|
56
|
+
_runtime(ctx).remove()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_envs(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
|
|
60
|
+
"""List environments and their live container status."""
|
|
61
|
+
report = _runtime(ctx).report()
|
|
62
|
+
if json:
|
|
63
|
+
console.json_out(report)
|
|
64
|
+
elif not report.envs:
|
|
65
|
+
console.info("No environments registered")
|
|
66
|
+
else:
|
|
67
|
+
for env in report.envs:
|
|
68
|
+
used = f" last used {env.last_used.astimezone().strftime('%Y-%m-%d %H:%M')}" if env.last_used else ""
|
|
69
|
+
console.info(f"{env.name} {env.host} {env.status} {env.worktree}{used}")
|
|
70
|
+
if env.fix:
|
|
71
|
+
console.warn(f"Fix: {env.fix}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def shell(ctx: typer.Context) -> None:
|
|
75
|
+
"""Open an interactive shell inside this environment."""
|
|
76
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
77
|
+
raise PreconditionError("An environment shell requires a TTY", "Run offbox shell in an interactive terminal.")
|
|
78
|
+
raise typer.Exit(_runtime(ctx).shell())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def gc(
|
|
82
|
+
ctx: typer.Context,
|
|
83
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show the plan without changing anything.")] = False,
|
|
84
|
+
yes: Annotated[bool, typer.Option("--yes", help="Apply the plan without a prompt.")] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Remove stale or orphan environments and prune unused inner Docker data."""
|
|
87
|
+
runtime = _runtime(ctx)
|
|
88
|
+
plan = runtime.gc_plan()
|
|
89
|
+
for target in plan.targets:
|
|
90
|
+
console.info(f"Remove {target.host}/{target.worktree.name}: {target.reason}")
|
|
91
|
+
for target in plan.running:
|
|
92
|
+
console.info(f"Prune unused Docker data in {target.host}/{target.worktree.name}")
|
|
93
|
+
if dry_run:
|
|
94
|
+
console.info("Dry run; no resources changed")
|
|
95
|
+
return
|
|
96
|
+
if not plan.targets and not plan.running:
|
|
97
|
+
console.info("Nothing to clean up")
|
|
98
|
+
return
|
|
99
|
+
_confirm("Apply this garbage collection plan?", yes, "Run offbox gc --dry-run to review, then offbox gc --yes.")
|
|
100
|
+
runtime.collect(plan)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def register(app: typer.Typer) -> None:
|
|
104
|
+
app.command()(up)
|
|
105
|
+
app.command()(stop)
|
|
106
|
+
app.command()(rm)
|
|
107
|
+
app.command("list")(list_envs)
|
|
108
|
+
app.command()(shell)
|
|
109
|
+
app.command()(gc)
|
offbox/cli/exec.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from pydantic import TypeAdapter
|
|
5
|
+
from typer._click import Context
|
|
6
|
+
from typer.core import TyperCommand
|
|
7
|
+
|
|
8
|
+
from offbox.errors import OffboxError, UsageError
|
|
9
|
+
from offbox.execute import ExecOptions, Executor, parse_env
|
|
10
|
+
from offbox.paths import Paths
|
|
11
|
+
from offbox.runner import SubprocessRunner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExecCommand(TyperCommand):
|
|
15
|
+
def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
|
|
16
|
+
if "--" in args:
|
|
17
|
+
index = args.index("--")
|
|
18
|
+
ctx.meta["offbox_exec_command"] = tuple(args[index + 1 :])
|
|
19
|
+
args = args[:index]
|
|
20
|
+
return super().parse_args(ctx, args)
|
|
21
|
+
|
|
22
|
+
def collect_usage_pieces(self, ctx: Context) -> list[str]:
|
|
23
|
+
return [*super().collect_usage_pieces(ctx), "-- COMMAND [ARGS]..."]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def execute(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
no_sync: Annotated[bool, typer.Option("--no-sync", help="Skip the source sync flush before running.")] = False,
|
|
29
|
+
tty: Annotated[bool, typer.Option("--tty", help="Allocate a remote TTY. Default: when stdin and stdout are terminals.")] = False,
|
|
30
|
+
no_tty: Annotated[bool, typer.Option("--no-tty", help="Never allocate a remote TTY.")] = False,
|
|
31
|
+
stdin: Annotated[bool, typer.Option("--stdin", help="Forward local stdin to a non-TTY command.")] = False,
|
|
32
|
+
env: Annotated[list[str] | None, typer.Option("--env", help="Set NAME=value in the command environment; repeatable.")] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Run a command after -- inside this worktree's environment."""
|
|
35
|
+
if tty and no_tty:
|
|
36
|
+
raise UsageError("--tty and --no-tty are mutually exclusive", "Choose one TTY mode.")
|
|
37
|
+
command = TypeAdapter(list[str]).validate_python(ctx.meta.get("offbox_exec_command", ()))
|
|
38
|
+
if not command:
|
|
39
|
+
raise UsageError("A command is required after --", "Run offbox exec -- COMMAND [ARGS...].")
|
|
40
|
+
runner: object = ctx.find_root().obj
|
|
41
|
+
if not isinstance(runner, SubprocessRunner):
|
|
42
|
+
raise OffboxError("Runner is unavailable", "Restart offbox.")
|
|
43
|
+
options = ExecOptions(
|
|
44
|
+
command=command, tty=True if tty else False if no_tty else None, no_sync=no_sync, stdin=stdin, overrides=parse_env(env or [])
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(Executor(Paths.discover(), runner).execute(options))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register(app: typer.Typer) -> None:
|
|
50
|
+
app.command("exec", cls=ExecCommand)(execute)
|