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.
@@ -0,0 +1,3 @@
1
+ """alpine-fleet: declaratively turn a disposable OCI micro VM into a hardened Alpine machine."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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