alpine-fleet 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.
- alpine_fleet/__init__.py +3 -0
- alpine_fleet/__main__.py +5 -0
- alpine_fleet/answerfiles/oci-e2-micro.answerfile +38 -0
- alpine_fleet/cli.py +150 -0
- alpine_fleet/doctor.py +125 -0
- alpine_fleet/paths.py +21 -0
- alpine_fleet/scripts/bootstrap.sh +135 -0
- alpine_fleet/scripts/console-connect.sh +151 -0
- alpine_fleet/scripts/discover-capacity.sh +75 -0
- alpine_fleet/scripts/launch-e2.sh +174 -0
- alpine_fleet/scripts/lib/oci-common.sh +177 -0
- alpine_fleet/scripts/orchestrate.py +610 -0
- alpine_fleet/scripts/prepare-kexec-cache.sh +50 -0
- alpine_fleet/scripts/teardown-e2.sh +61 -0
- alpine_fleet-0.1.0.dist-info/METADATA +153 -0
- alpine_fleet-0.1.0.dist-info/RECORD +23 -0
- alpine_fleet-0.1.0.dist-info/WHEEL +4 -0
- alpine_fleet-0.1.0.dist-info/entry_points.txt +2 -0
- alpine_fleet-0.1.0.dist-info/licenses/LICENSE +21 -0
- alpine_fleet-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.txt +15 -0
- alpine_fleet-0.1.0.dist-info/licenses/licenses/oci-cli.txt +82 -0
- alpine_fleet-0.1.0.dist-info/licenses/licenses/pexpect.txt +20 -0
- alpine_fleet-0.1.0.dist-info/licenses/licenses/ptyprocess.txt +16 -0
alpine_fleet/__init__.py
ADDED
alpine_fleet/__main__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Non-interactive answerfile for `setup-alpine -f <this-file>`
|
|
2
|
+
# Tuned for OCI VM.Standard.E2.1.Micro (and similar small free-tier shapes).
|
|
3
|
+
#
|
|
4
|
+
# Usage (from the live Alpine environment after kexec):
|
|
5
|
+
# setup-alpine -f oci-e2-micro.answerfile
|
|
6
|
+
#
|
|
7
|
+
# NOTE: this uses DHCP rather than a static IP + manual netmask, since
|
|
8
|
+
# OCI's VCN subnets hand out DHCP correctly and it sidesteps having to
|
|
9
|
+
# guess/know the subnet's real netmask (setup-alpine's default guess
|
|
10
|
+
# based on IP class is very often wrong for cloud VCNs).
|
|
11
|
+
|
|
12
|
+
KEYMAPOPTS="us us"
|
|
13
|
+
HOSTNAMEOPTS="-n alpine-worker"
|
|
14
|
+
|
|
15
|
+
INTERFACESOPTS="auto lo
|
|
16
|
+
iface lo inet loopback
|
|
17
|
+
|
|
18
|
+
auto eth0
|
|
19
|
+
iface eth0 inet dhcp
|
|
20
|
+
"
|
|
21
|
+
|
|
22
|
+
# 169.254.169.254 is OCI's metadata service, which proxies DNS correctly
|
|
23
|
+
# inside the VCN. Adjust for other providers.
|
|
24
|
+
DNSOPTS="-d '' 169.254.169.254"
|
|
25
|
+
|
|
26
|
+
TIMEZONEOPTS="-z UTC"
|
|
27
|
+
PROXYOPTS="none"
|
|
28
|
+
APKREPOSOPTS="-1"
|
|
29
|
+
SSHDOPTS="-c openssh"
|
|
30
|
+
NTPOPTS="-c chrony"
|
|
31
|
+
|
|
32
|
+
# sda is OCI's boot volume device name for these shapes. Verify with
|
|
33
|
+
# `lsblk` in the live environment before trusting this blindly on a
|
|
34
|
+
# different provider/shape.
|
|
35
|
+
DISKOPTS="-m sys /dev/sda"
|
|
36
|
+
|
|
37
|
+
LBUOPTS="none"
|
|
38
|
+
APKCACHEOPTS="none"
|
alpine_fleet/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""alpine-fleet command line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, doctor, paths
|
|
11
|
+
|
|
12
|
+
PKG = Path(__file__).resolve().parent
|
|
13
|
+
SCRIPTS = PKG / "scripts"
|
|
14
|
+
ANSWERFILE = PKG / "answerfiles" / "oci-e2-micro.answerfile"
|
|
15
|
+
|
|
16
|
+
WARNING = """\
|
|
17
|
+
WARNING: this replaces the target instance's operating system with Alpine Linux
|
|
18
|
+
and erases its boot disk (/dev/sda). Use it only on disposable or recoverable
|
|
19
|
+
instances. Root ends up key-only (no password): if you lose your private key,
|
|
20
|
+
rebuild the instance."""
|
|
21
|
+
|
|
22
|
+
EPILOG = WARNING + """
|
|
23
|
+
|
|
24
|
+
commands:
|
|
25
|
+
doctor check host tools (ssh, jq, oci, bash>=4) and print install commands
|
|
26
|
+
discover show E2.1.Micro / A1.Flex headroom per availability domain
|
|
27
|
+
up launch an E2.1.Micro if needed and convert it to Alpine
|
|
28
|
+
convert convert an existing instance: convert <user@ip> <instance-ocid>
|
|
29
|
+
down terminate the instance recorded in the state file
|
|
30
|
+
cache build the Oracle Linux 7 kexec cache (faster runs; needs podman/docker)
|
|
31
|
+
|
|
32
|
+
Extra flags for discover/down/cache are passed to the underlying script
|
|
33
|
+
(e.g. `alpine-fleet down --yes`, `alpine-fleet cache --force`)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _env() -> dict[str, str]:
|
|
37
|
+
env = dict(os.environ)
|
|
38
|
+
if not env.get("OCI_BIN"):
|
|
39
|
+
found = doctor.find_oci()
|
|
40
|
+
if found:
|
|
41
|
+
env["OCI_BIN"] = found
|
|
42
|
+
env.setdefault("ALPINE_FLEET_STATE_DIR", str(paths.state_dir()))
|
|
43
|
+
env.setdefault("KEXEC_CACHE", str(paths.kexec_cache()))
|
|
44
|
+
return env
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bash(script: str, extra: list[str]) -> int:
|
|
48
|
+
# Always via `bash`: wheels and copies can lose exec bits.
|
|
49
|
+
return subprocess.call(["bash", str(SCRIPTS / script), *extra], env=_env())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _confirm(expect: str | None, yes: bool) -> bool:
|
|
53
|
+
print(WARNING, file=sys.stderr)
|
|
54
|
+
if yes:
|
|
55
|
+
return True
|
|
56
|
+
if not sys.stdin.isatty():
|
|
57
|
+
print("Refusing to continue without --yes (stdin is not a terminal).", file=sys.stderr)
|
|
58
|
+
return False
|
|
59
|
+
if expect:
|
|
60
|
+
return input(f"\nType the target's address ({expect}) to continue: ").strip() == expect
|
|
61
|
+
return input("\nContinue? [y/N] ").strip().lower() == "y"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _orchestrate(args: argparse.Namespace, target: str | None = None, ocid: str | None = None) -> int:
|
|
65
|
+
missing = doctor.blocking()
|
|
66
|
+
if missing:
|
|
67
|
+
print("Missing requirements: " + ", ".join(missing) + "\nRun: alpine-fleet doctor", file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
expect = target.split("@", 1)[-1] if target else None
|
|
70
|
+
if not _confirm(expect, args.yes):
|
|
71
|
+
print("Aborted.", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
state = paths.state_dir()
|
|
74
|
+
cmd = [sys.executable, str(SCRIPTS / "orchestrate.py"),
|
|
75
|
+
"--script-dir", str(SCRIPTS),
|
|
76
|
+
"--answerfile", args.answerfile,
|
|
77
|
+
"--state-file", str(state / "current-instance.json"),
|
|
78
|
+
"--serial-log", str(state / "serial.log"),
|
|
79
|
+
"--alpine-version", args.alpine_version,
|
|
80
|
+
"--ssh-pubkey", args.ssh_pubkey]
|
|
81
|
+
if args.console_key:
|
|
82
|
+
cmd += ["--key", args.console_key]
|
|
83
|
+
if args.debug:
|
|
84
|
+
cmd.append("--debug")
|
|
85
|
+
if getattr(args, "no_launch", False):
|
|
86
|
+
cmd.append("--no-launch")
|
|
87
|
+
if target:
|
|
88
|
+
cmd += [target, ocid]
|
|
89
|
+
return subprocess.call(cmd, env=_env())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _add_run_flags(p: argparse.ArgumentParser) -> None:
|
|
93
|
+
p.add_argument("--yes", "-y", action="store_true", help="skip the destructive-action confirmation")
|
|
94
|
+
p.add_argument("--debug", "-v", action="store_true", help="echo the raw serial console and bootstrap output")
|
|
95
|
+
p.add_argument("--alpine-version", default="v3.24", help="Alpine release branch (default: %(default)s)")
|
|
96
|
+
p.add_argument("--ssh-pubkey", default=str(Path.home() / ".ssh" / "id_ed25519.pub"),
|
|
97
|
+
help="public key installed for root (default: %(default)s)")
|
|
98
|
+
p.add_argument("--console-key", default=None, help="RSA key for the OCI serial console (default: ~/.ssh/oci-console-rsa)")
|
|
99
|
+
p.add_argument("--answerfile", default=str(ANSWERFILE), help="setup-alpine answerfile (default: the bundled OCI one)")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
103
|
+
ap = argparse.ArgumentParser(
|
|
104
|
+
prog="alpine-fleet",
|
|
105
|
+
description="Turn a disposable OCI micro VM into a lightweight, hardened Alpine machine.",
|
|
106
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
107
|
+
ap.add_argument("--version", action="version", version=f"alpine-fleet {__version__}")
|
|
108
|
+
sub = ap.add_subparsers(dest="cmd", metavar="<command>")
|
|
109
|
+
|
|
110
|
+
d = sub.add_parser("doctor", help="check host requirements")
|
|
111
|
+
d.add_argument("--fix", action="store_true", help="offer to run the install command")
|
|
112
|
+
|
|
113
|
+
sub.add_parser("discover", help="show free-tier capacity per availability domain", add_help=False)
|
|
114
|
+
|
|
115
|
+
up = sub.add_parser("up", help="launch (if needed) and convert to Alpine")
|
|
116
|
+
_add_run_flags(up)
|
|
117
|
+
up.add_argument("--no-launch", action="store_true", help="never launch; use the recorded instance")
|
|
118
|
+
|
|
119
|
+
cv = sub.add_parser("convert", help="convert an existing instance")
|
|
120
|
+
cv.add_argument("target", metavar="USER@IP", help="stock-OS login, e.g. opc@203.0.113.7")
|
|
121
|
+
cv.add_argument("instance_ocid", metavar="INSTANCE_OCID")
|
|
122
|
+
_add_run_flags(cv)
|
|
123
|
+
|
|
124
|
+
sub.add_parser("down", help="terminate the recorded instance", add_help=False)
|
|
125
|
+
sub.add_parser("cache", help="build the kexec cache", add_help=False)
|
|
126
|
+
return ap
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: list[str] | None = None) -> int:
|
|
130
|
+
ap = build_parser()
|
|
131
|
+
args, extra = ap.parse_known_args(argv)
|
|
132
|
+
if args.cmd is None:
|
|
133
|
+
ap.print_help()
|
|
134
|
+
return 0
|
|
135
|
+
if args.cmd in ("doctor", "up", "convert") and extra:
|
|
136
|
+
ap.error("unrecognized arguments: " + " ".join(extra))
|
|
137
|
+
|
|
138
|
+
if args.cmd == "doctor":
|
|
139
|
+
return doctor.run(fix=args.fix)
|
|
140
|
+
if args.cmd == "discover":
|
|
141
|
+
return _bash("discover-capacity.sh", extra)
|
|
142
|
+
if args.cmd == "down":
|
|
143
|
+
return _bash("teardown-e2.sh", extra)
|
|
144
|
+
if args.cmd == "cache":
|
|
145
|
+
return _bash("prepare-kexec-cache.sh", extra)
|
|
146
|
+
if args.cmd == "up":
|
|
147
|
+
return _orchestrate(args)
|
|
148
|
+
if args.cmd == "convert":
|
|
149
|
+
return _orchestrate(args, args.target, args.instance_ocid)
|
|
150
|
+
return 1
|
alpine_fleet/doctor.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`alpine-fleet doctor`: report missing host tools and print (or run) the install command.
|
|
2
|
+
|
|
3
|
+
pip cannot install system packages, so this never runs at install time and only
|
|
4
|
+
runs a package-manager command after an explicit confirmation.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# (tools that must exist, {package manager: package that provides them})
|
|
15
|
+
SYSTEM_GROUPS = [
|
|
16
|
+
(["ssh", "scp", "ssh-keygen"], {
|
|
17
|
+
"pacman": "openssh", "apt": "openssh-client", "dnf": "openssh-clients",
|
|
18
|
+
"zypper": "openssh-clients", "apk": "openssh-client", "brew": "openssh"}),
|
|
19
|
+
(["jq"], {p: "jq" for p in ("pacman", "apt", "dnf", "zypper", "apk", "brew")}),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
INSTALL = {
|
|
23
|
+
"pacman": ["pacman", "-S", "--needed"],
|
|
24
|
+
"apt": ["apt", "install"],
|
|
25
|
+
"dnf": ["dnf", "install"],
|
|
26
|
+
"zypper": ["zypper", "install"],
|
|
27
|
+
"apk": ["apk", "add"],
|
|
28
|
+
"brew": ["brew", "install"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detect_pm() -> str | None:
|
|
33
|
+
return next((pm for pm in INSTALL if shutil.which(pm)), None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bash_major() -> int:
|
|
37
|
+
bash = shutil.which("bash")
|
|
38
|
+
if not bash:
|
|
39
|
+
return 0
|
|
40
|
+
try:
|
|
41
|
+
out = subprocess.check_output([bash, "-c", "echo ${BASH_VERSINFO[0]}"], text=True)
|
|
42
|
+
return int(out.strip())
|
|
43
|
+
except (subprocess.SubprocessError, ValueError, OSError):
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_oci() -> str | None:
|
|
48
|
+
if os.environ.get("OCI_BIN"):
|
|
49
|
+
return os.environ["OCI_BIN"]
|
|
50
|
+
beside_python = Path(sys.executable).parent / "oci"
|
|
51
|
+
return str(beside_python) if beside_python.exists() else shutil.which("oci")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def oci_config_path() -> Path:
|
|
55
|
+
return Path(os.environ.get("OCI_CONFIG_FILE", "~/.oci/config")).expanduser()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check() -> tuple[list[tuple[str, str, str]], list[str]]:
|
|
59
|
+
"""Return (rows, packages_to_install). Row = (status, label, hint); status is
|
|
60
|
+
ok / MISSING (blocks a run) / optional."""
|
|
61
|
+
rows: list[tuple[str, str, str]] = []
|
|
62
|
+
pm = detect_pm()
|
|
63
|
+
pkgs: list[str] = []
|
|
64
|
+
|
|
65
|
+
for tools, by_pm in SYSTEM_GROUPS:
|
|
66
|
+
missing = [t for t in tools if not shutil.which(t)]
|
|
67
|
+
label = "/".join(tools)
|
|
68
|
+
if not missing:
|
|
69
|
+
rows.append(("ok", label, ""))
|
|
70
|
+
else:
|
|
71
|
+
pkg = by_pm.get(pm or "")
|
|
72
|
+
rows.append(("MISSING", label, f"install package '{pkg}'" if pkg else "install with your package manager"))
|
|
73
|
+
if pkg and pkg not in pkgs:
|
|
74
|
+
pkgs.append(pkg)
|
|
75
|
+
|
|
76
|
+
major = bash_major()
|
|
77
|
+
if major >= 4:
|
|
78
|
+
rows.append(("ok", "bash >= 4", ""))
|
|
79
|
+
else:
|
|
80
|
+
hint = "macOS ships bash 3.2; run: brew install bash" if sys.platform == "darwin" else "install bash 4 or newer"
|
|
81
|
+
rows.append(("MISSING", "bash >= 4", hint))
|
|
82
|
+
|
|
83
|
+
if find_oci():
|
|
84
|
+
rows.append(("ok", "oci CLI", ""))
|
|
85
|
+
else:
|
|
86
|
+
rows.append(("MISSING", "oci CLI", "pipx install 'alpine-fleet[oci]' (or: pip install oci-cli)"))
|
|
87
|
+
|
|
88
|
+
if oci_config_path().exists():
|
|
89
|
+
rows.append(("ok", "OCI config", str(oci_config_path())))
|
|
90
|
+
else:
|
|
91
|
+
rows.append(("MISSING", "OCI config", "run: oci setup config"))
|
|
92
|
+
|
|
93
|
+
if shutil.which("podman") or shutil.which("docker"):
|
|
94
|
+
rows.append(("ok", "podman/docker", ""))
|
|
95
|
+
else:
|
|
96
|
+
rows.append(("optional", "podman/docker", "only needed for `alpine-fleet cache` (faster runs)"))
|
|
97
|
+
|
|
98
|
+
return rows, pkgs
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def blocking() -> list[str]:
|
|
102
|
+
rows, _ = check()
|
|
103
|
+
return [label for status, label, _ in rows if status == "MISSING"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run(fix: bool = False) -> int:
|
|
107
|
+
rows, pkgs = check()
|
|
108
|
+
for status, label, hint in rows:
|
|
109
|
+
mark = {"ok": "ok ", "MISSING": "MISSING ", "optional": "optional"}[status]
|
|
110
|
+
print(f" [{mark}] {label:<16} {hint}")
|
|
111
|
+
|
|
112
|
+
pm = detect_pm()
|
|
113
|
+
if pkgs and pm:
|
|
114
|
+
cmd = list(INSTALL[pm]) + pkgs
|
|
115
|
+
if pm != "brew" and os.geteuid() != 0:
|
|
116
|
+
cmd = ["sudo"] + cmd
|
|
117
|
+
print("\nInstall the missing system packages with:\n " + " ".join(cmd))
|
|
118
|
+
if fix:
|
|
119
|
+
if input("Run it now? [y/N] ").strip().lower() == "y":
|
|
120
|
+
subprocess.call(cmd)
|
|
121
|
+
return 0 if not blocking() else 1
|
|
122
|
+
elif pkgs:
|
|
123
|
+
print("\nNo known package manager found; install: " + ", ".join(pkgs))
|
|
124
|
+
|
|
125
|
+
return 1 if blocking() else 0
|
alpine_fleet/paths.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""User-level state/cache locations (the install directory is never written to)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _xdg(var: str, fallback: str) -> Path:
|
|
9
|
+
return Path(os.environ.get(var) or Path.home() / fallback)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def state_dir() -> Path:
|
|
13
|
+
"""Instance state file and serial transcripts (contain instance IDs and IPs)."""
|
|
14
|
+
override = os.environ.get("ALPINE_FLEET_STATE_DIR")
|
|
15
|
+
return Path(override) if override else _xdg("XDG_STATE_HOME", ".local/state") / "alpine-fleet"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def kexec_cache() -> Path:
|
|
19
|
+
"""Host-side cache of kexec binaries built for the target's stock OS."""
|
|
20
|
+
override = os.environ.get("KEXEC_CACHE")
|
|
21
|
+
return Path(override) if override else _xdg("XDG_CACHE_HOME", ".cache") / "alpine-fleet" / "kexec"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# alpine-fleet bootstrap.sh
|
|
4
|
+
#
|
|
5
|
+
# Stages a kexec jump from a running cloud VM's current OS into Alpine
|
|
6
|
+
# Linux's netboot installer with an embedded apkovl answerfile overlay.
|
|
7
|
+
#
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
TARGET="${1:?Usage: bootstrap.sh <user>@<ip> [alpine-version] [answerfile]}"
|
|
11
|
+
ALPINE_VERSION="${2:-v3.24}"
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
ANSWERFILE="${3:-$SCRIPT_DIR/../answerfiles/oci-e2-micro.answerfile}"
|
|
14
|
+
ARCH="x86_64"
|
|
15
|
+
|
|
16
|
+
[[ -f "$ANSWERFILE" ]] || { echo "FATAL: answerfile not found at $ANSWERFILE" >&2; exit 1; }
|
|
17
|
+
ANSWERFILE_B64="$(base64 < "$ANSWERFILE" | tr -d '\n')"
|
|
18
|
+
|
|
19
|
+
TARGET_USER="${TARGET%%@*}"
|
|
20
|
+
if [[ "$TARGET_USER" == "root" ]]; then
|
|
21
|
+
echo "!! OCI's stock images (opc-based) reject root SSH logins outright." >&2
|
|
22
|
+
echo "!! Use the image's default user instead, e.g.: opc@${TARGET#*@}" >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
MIRROR="https://dl-cdn.alpinelinux.org/alpine/${ALPINE_VERSION}/releases/${ARCH}/netboot"
|
|
27
|
+
REPO="http://dl-cdn.alpinelinux.org/alpine/${ALPINE_VERSION}/main"
|
|
28
|
+
|
|
29
|
+
echo "== alpine-fleet bootstrap =="
|
|
30
|
+
echo "Target: $TARGET"
|
|
31
|
+
echo "Version: $ALPINE_VERSION"
|
|
32
|
+
echo "Answerfile: $ANSWERFILE"
|
|
33
|
+
echo
|
|
34
|
+
echo "!! Before continuing, make sure you have a serial/console connection"
|
|
35
|
+
echo "!! open and confirmed working for this instance. This is your only"
|
|
36
|
+
echo "!! visibility once the kexec jump happens — SSH will die instantly."
|
|
37
|
+
read -r -p "Console confirmed and watching? [y/N] " confirm
|
|
38
|
+
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
|
39
|
+
echo "Aborting. Set up console access first."
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# ---- optional host-side kexec cache -------------------------------------------
|
|
44
|
+
# `kexec` has to run on the TARGET'S CURRENT OS (e.g. Oracle Linux 7), not on
|
|
45
|
+
# Alpine and not on this machine, so the cached binary must be built for that OS
|
|
46
|
+
# (see scripts/prepare-kexec-cache.sh). Layout: cache/kexec/<os-id>-<major>/kexec,
|
|
47
|
+
# e.g. cache/kexec/ol-7/kexec. A fully static binary in cache/kexec/static/kexec
|
|
48
|
+
# works on any x86_64 Linux. Set NO_KEXEC_CACHE=1 to ignore the cache.
|
|
49
|
+
KEXEC_CACHE="${KEXEC_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/alpine-fleet/kexec}"
|
|
50
|
+
USE_CACHED_KEXEC=0
|
|
51
|
+
if [[ "${NO_KEXEC_CACHE:-0}" != "1" ]]; then
|
|
52
|
+
OS_ID="$(ssh -o BatchMode=yes "$TARGET" '. /etc/os-release && echo "${ID}-${VERSION_ID%%.*}"' 2>/dev/null || true)"
|
|
53
|
+
for cand in "$KEXEC_CACHE/${OS_ID:-unknown}/kexec" "$KEXEC_CACHE/static/kexec"; do
|
|
54
|
+
if [[ -x "$cand" ]]; then
|
|
55
|
+
if scp -q -o BatchMode=yes "$cand" "$TARGET:/tmp/kexec-cached"; then
|
|
56
|
+
USE_CACHED_KEXEC=1
|
|
57
|
+
echo "kexec cache: HIT ($cand) for target OS '${OS_ID:-unknown}'"
|
|
58
|
+
break
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
done
|
|
62
|
+
[[ $USE_CACHED_KEXEC == 1 ]] || echo "kexec cache: miss for target OS '${OS_ID:-unknown}' — will install kexec-tools on the target"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
SSH_OUT=$(mktemp)
|
|
66
|
+
set +e
|
|
67
|
+
ssh -o BatchMode=yes "$TARGET" bash -s <<REMOTE | tee "$SSH_OUT"
|
|
68
|
+
set -euo pipefail
|
|
69
|
+
|
|
70
|
+
cd /tmp
|
|
71
|
+
# Start the two netboot downloads in the background NOW: they are network-bound,
|
|
72
|
+
# while the package install below is CPU/disk-bound on a 1-vCPU box, so the two
|
|
73
|
+
# overlap instead of running back to back. Waited on (and checked) further down.
|
|
74
|
+
echo "[remote] fetching Alpine netboot kernel + initramfs (background)..."
|
|
75
|
+
( wget -q "${MIRROR}/vmlinuz-lts" -O /tmp/vmlinuz-lts && echo "[remote] kernel downloaded (t+\${SECONDS}s)" ) &
|
|
76
|
+
DL_KERNEL=\$!
|
|
77
|
+
( wget -q "${MIRROR}/initramfs-lts" -O /tmp/initramfs-lts && echo "[remote] initramfs downloaded (t+\${SECONDS}s)" ) &
|
|
78
|
+
DL_INITRD=\$!
|
|
79
|
+
|
|
80
|
+
if [ -x /usr/sbin/kexec ] && /usr/sbin/kexec --version >/dev/null 2>&1; then
|
|
81
|
+
echo "[remote] kexec already present on the target — nothing to install"
|
|
82
|
+
elif [ "$USE_CACHED_KEXEC" = 1 ] && sudo install -m 0755 /tmp/kexec-cached /usr/sbin/kexec && /usr/sbin/kexec --version >/dev/null 2>&1; then
|
|
83
|
+
echo "[remote] using host-cached kexec: \$(/usr/sbin/kexec --version 2>&1 | head -1) — package install skipped"
|
|
84
|
+
else
|
|
85
|
+
echo "[remote] cached kexec unavailable or unusable — installing kexec-tools..."
|
|
86
|
+
sudo rm -f /usr/sbin/kexec # drop a cached binary that failed its --version check
|
|
87
|
+
if command -v apt >/dev/null; then
|
|
88
|
+
sudo apt-get update -qq && sudo apt-get install -y -qq kexec-tools
|
|
89
|
+
elif command -v dnf >/dev/null; then
|
|
90
|
+
sudo dnf install -y kexec-tools
|
|
91
|
+
elif command -v yum >/dev/null; then
|
|
92
|
+
echo "[remote] busiest processes at start: \$(ps -eo comm --sort=-pcpu | sed 1d | head -4 | tr '\n' ' ') (t+\${SECONDS}s)"
|
|
93
|
+
sudo yum makecache -q
|
|
94
|
+
echo "[remote] yum metadata cached (t+\${SECONDS}s)"
|
|
95
|
+
sudo yum install -y kexec-tools
|
|
96
|
+
elif command -v apk >/dev/null; then
|
|
97
|
+
sudo apk add kexec-tools
|
|
98
|
+
else
|
|
99
|
+
echo "[remote] unknown package manager, install kexec-tools manually" >&2
|
|
100
|
+
exit 1
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
echo "[remote] kexec-tools ready (t+\${SECONDS}s)"
|
|
104
|
+
|
|
105
|
+
echo "[remote] baking answerfile into an apkovl overlay..."
|
|
106
|
+
rm -rf /tmp/overlay /tmp/overlay.cpio.gz
|
|
107
|
+
mkdir -p /tmp/overlay/root
|
|
108
|
+
echo "$ANSWERFILE_B64" | base64 -d > /tmp/overlay/root/answers
|
|
109
|
+
chmod 600 /tmp/overlay/root/answers
|
|
110
|
+
(cd /tmp/overlay && find . | cpio -H newc -o 2>/dev/null | gzip -9 > /tmp/overlay.cpio.gz)
|
|
111
|
+
|
|
112
|
+
wait \$DL_KERNEL || { echo "[remote] kernel download failed" >&2; exit 1; }
|
|
113
|
+
wait \$DL_INITRD || { echo "[remote] initramfs download failed" >&2; exit 1; }
|
|
114
|
+
echo "[remote] netboot files downloaded (t+\${SECONDS}s)"
|
|
115
|
+
cat /tmp/initramfs-lts /tmp/overlay.cpio.gz > /tmp/initramfs-bundled
|
|
116
|
+
|
|
117
|
+
echo "[remote] staging kexec with bundled overlay..."
|
|
118
|
+
sudo kexec -l /tmp/vmlinuz-lts --initrd=/tmp/initramfs-bundled \
|
|
119
|
+
--append="ip=dhcp alpine_repo=${REPO} modloop=${MIRROR}/modloop-lts console=tty0 console=ttyS0,115200"
|
|
120
|
+
|
|
121
|
+
echo "[remote] KEXEC_STAGE_OK (t+\${SECONDS}s)"
|
|
122
|
+
REMOTE
|
|
123
|
+
SSH_EXIT=$?
|
|
124
|
+
set -e
|
|
125
|
+
|
|
126
|
+
if [[ $SSH_EXIT -ne 0 ]] || ! grep -q "KEXEC_STAGE_OK" "$SSH_OUT"; then
|
|
127
|
+
echo
|
|
128
|
+
echo "!! Staging FAILED (ssh exit=$SSH_EXIT). Nothing jumped." >&2
|
|
129
|
+
rm -f "$SSH_OUT"
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
|
132
|
+
rm -f "$SSH_OUT"
|
|
133
|
+
|
|
134
|
+
echo
|
|
135
|
+
echo "== Staging confirmed on remote host =="
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# console-connect.sh — get (or create) an OCI serial console connection,
|
|
4
|
+
# auto-discovering the target instance and managing a persistent RSA
|
|
5
|
+
# key, then pull the connection-string out of the JSON response, patch
|
|
6
|
+
# in the key + safety flags, and either print or exec the result.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ./console-connect.sh [--instance-id <ocid>] [--key <path>] [--exec]
|
|
10
|
+
#
|
|
11
|
+
# --instance-id: optional. If omitted, this looks at every RUNNING
|
|
12
|
+
# instance in the compartment (via oci-common.sh). If exactly one is
|
|
13
|
+
# running, it's used automatically. If there's more than one, you'll
|
|
14
|
+
# get a list and have to re-run with --instance-id to disambiguate —
|
|
15
|
+
# this script will not guess which of several live boxes you meant.
|
|
16
|
+
#
|
|
17
|
+
# --key: optional, default ~/.ssh/oci-console-rsa. OCI's serial console
|
|
18
|
+
# requires an RSA key specifically — Ed25519 keys are rejected by the
|
|
19
|
+
# console proxy even though they work fine for normal instance SSH.
|
|
20
|
+
# If the key doesn't exist yet, it's generated (RSA 4096, no
|
|
21
|
+
# passphrase — needed since this runs non-interactively) and PERSISTED
|
|
22
|
+
# to disk, not thrown away after use: the console-connection resource
|
|
23
|
+
# in OCI is tied to the public key you registered when you created it,
|
|
24
|
+
# so reusing the same key on future runs lets this script find and
|
|
25
|
+
# reuse that existing connection instead of creating a new one every
|
|
26
|
+
# time (you're capped at 10 console connections per tenancy).
|
|
27
|
+
# Since there's no passphrase, treat this key file like any other
|
|
28
|
+
# unlocked credential — restrict its permissions (this script does,
|
|
29
|
+
# via `ssh-keygen`'s default 600) and don't copy it elsewhere.
|
|
30
|
+
#
|
|
31
|
+
# --exec: connect immediately instead of just printing the command.
|
|
32
|
+
#
|
|
33
|
+
set -euo pipefail
|
|
34
|
+
|
|
35
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
36
|
+
source "$SCRIPT_DIR/lib/oci-common.sh"
|
|
37
|
+
|
|
38
|
+
INSTANCE_ID=""
|
|
39
|
+
PRIVATE_KEY="$HOME/.ssh/oci-console-rsa"
|
|
40
|
+
DO_EXEC=false
|
|
41
|
+
|
|
42
|
+
while [[ $# -gt 0 ]]; do
|
|
43
|
+
case "$1" in
|
|
44
|
+
--instance-id) INSTANCE_ID="$2"; shift 2 ;;
|
|
45
|
+
--key) PRIVATE_KEY="$2"; shift 2 ;;
|
|
46
|
+
--exec) DO_EXEC=true; shift ;;
|
|
47
|
+
*)
|
|
48
|
+
echo "Unknown argument: $1" >&2
|
|
49
|
+
echo "Usage: console-connect.sh [--instance-id <ocid>] [--key <path>] [--exec]" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
command -v jq >/dev/null || {
|
|
56
|
+
echo "FATAL: jq is required here — the connection-string contains spaces" >&2
|
|
57
|
+
echo "and quoting that naive grep/sed would mangle. Install jq and retry." >&2
|
|
58
|
+
exit 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
OCI_BIN="$(oci_resolve_bin)"
|
|
62
|
+
COMPARTMENT_ID="$(oci_resolve_compartment_id)"
|
|
63
|
+
|
|
64
|
+
# ---- Instance auto-discovery ----
|
|
65
|
+
if [ -z "$INSTANCE_ID" ]; then
|
|
66
|
+
echo "[console] no --instance-id given — looking for running instances..." >&2
|
|
67
|
+
RUNNING_JSON="$(oci_list_running_instances "$OCI_BIN" "$COMPARTMENT_ID")"
|
|
68
|
+
COUNT="$(echo "$RUNNING_JSON" | jq 'length')"
|
|
69
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
70
|
+
echo "FATAL: no RUNNING instances found in this compartment." >&2
|
|
71
|
+
exit 1
|
|
72
|
+
elif [ "$COUNT" -eq 1 ]; then
|
|
73
|
+
INSTANCE_ID="$(echo "$RUNNING_JSON" | jq -r '.[0].id')"
|
|
74
|
+
NAME="$(echo "$RUNNING_JSON" | jq -r '.[0].name')"
|
|
75
|
+
echo "[console] found exactly one — using '$NAME' ($INSTANCE_ID)" >&2
|
|
76
|
+
else
|
|
77
|
+
echo "FATAL: $COUNT running instances found — ambiguous. Pick one:" >&2
|
|
78
|
+
echo "$RUNNING_JSON" | jq -r '.[] | " \(.name)\t\(.shape)\t\(.id)"' >&2
|
|
79
|
+
echo "Re-run with: --instance-id <ocid>" >&2
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
# ---- RSA key: reuse if present, generate+persist if not ----
|
|
85
|
+
if [ -f "$PRIVATE_KEY" ]; then
|
|
86
|
+
KEY_TYPE="$(ssh-keygen -l -f "$PRIVATE_KEY" 2>/dev/null | grep -o '(RSA)' || true)"
|
|
87
|
+
if [ -z "$KEY_TYPE" ]; then
|
|
88
|
+
echo "FATAL: $PRIVATE_KEY exists but isn't an RSA key — OCI's serial" >&2
|
|
89
|
+
echo "console requires RSA specifically (Ed25519 is rejected). Pass a" >&2
|
|
90
|
+
echo "different --key path, or move this file aside and re-run to" >&2
|
|
91
|
+
echo "generate a fresh RSA key at the default path." >&2
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
echo "[console] reusing existing RSA key at $PRIVATE_KEY" >&2
|
|
95
|
+
else
|
|
96
|
+
echo "[console] no key at $PRIVATE_KEY — generating a new RSA 4096 key..." >&2
|
|
97
|
+
mkdir -p "$(dirname "$PRIVATE_KEY")"
|
|
98
|
+
ssh-keygen -t rsa -b 4096 -N "" -f "$PRIVATE_KEY" -C "oci-console-connect" >&2
|
|
99
|
+
fi
|
|
100
|
+
PUB_KEY="${PRIVATE_KEY}.pub"
|
|
101
|
+
|
|
102
|
+
# ---- Find or create the console connection ----
|
|
103
|
+
echo "[console] checking for an existing active console connection..." >&2
|
|
104
|
+
EXISTING_JSON="$("$OCI_BIN" compute instance-console-connection list \
|
|
105
|
+
--compartment-id "$COMPARTMENT_ID" \
|
|
106
|
+
--instance-id "$INSTANCE_ID" \
|
|
107
|
+
--output json)"
|
|
108
|
+
|
|
109
|
+
CONN_ID="$(echo "$EXISTING_JSON" | jq -r '.data[] | select(."lifecycle-state"=="ACTIVE") | .id' | head -1)"
|
|
110
|
+
|
|
111
|
+
if [ -z "$CONN_ID" ] || [ "$CONN_ID" = "null" ]; then
|
|
112
|
+
echo "[console] none found — creating one (this can take ~30-60s)..." >&2
|
|
113
|
+
CONN_JSON="$("$OCI_BIN" compute instance-console-connection create \
|
|
114
|
+
--instance-id "$INSTANCE_ID" \
|
|
115
|
+
--ssh-public-key-file "$PUB_KEY" \
|
|
116
|
+
--wait-for-state ACTIVE \
|
|
117
|
+
--output json)"
|
|
118
|
+
else
|
|
119
|
+
echo "[console] reusing existing active connection $CONN_ID" >&2
|
|
120
|
+
CONN_JSON="$("$OCI_BIN" compute instance-console-connection get \
|
|
121
|
+
--instance-console-connection-id "$CONN_ID" \
|
|
122
|
+
--output json)"
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
RAW_CONN_STR="$(echo "$CONN_JSON" | jq -r '.data."connection-string"')"
|
|
126
|
+
if [ -z "$RAW_CONN_STR" ] || [ "$RAW_CONN_STR" = "null" ]; then
|
|
127
|
+
echo "FATAL: could not extract connection-string from the JSON response:" >&2
|
|
128
|
+
echo "$CONN_JSON" >&2
|
|
129
|
+
exit 1
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# Two nested ssh invocations — outer + ProxyCommand — both need: the
|
|
133
|
+
# private key (OCI's string never embeds a key path; it assumes
|
|
134
|
+
# ssh-agent or a default identity, which won't be true here),
|
|
135
|
+
# ControlPath=none (the proxy's long OCID-as-username + hostname
|
|
136
|
+
# routinely blows past the ~104-108 byte Unix socket path limit on the
|
|
137
|
+
# default multiplexing ControlPath template), and disabled strict host
|
|
138
|
+
# checking (the console's host key has nothing to do with the
|
|
139
|
+
# instance's own).
|
|
140
|
+
SAFE_FLAGS="-i ${PRIVATE_KEY} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=none"
|
|
141
|
+
|
|
142
|
+
# Bash parameter-expansion global replace — avoids sed delimiter clashes
|
|
143
|
+
# with slashes in $PRIVATE_KEY.
|
|
144
|
+
PATCHED="${RAW_CONN_STR//ssh /ssh $SAFE_FLAGS }"
|
|
145
|
+
|
|
146
|
+
echo "$PATCHED"
|
|
147
|
+
|
|
148
|
+
if $DO_EXEC; then
|
|
149
|
+
echo "[console] connecting..." >&2
|
|
150
|
+
exec bash -c "$PATCHED"
|
|
151
|
+
fi
|