refactor(provisioning): remove manual SSH bootstrap workflow
This commit is contained in:
1 parent
f9091d116a
commit
7ad4473e93
9 files changed
+70
-1041
No files matched your search
@@ -0,0 +1,51 @@
|
||||
"""Deterministic provisioning payloads for golden-image builds."""
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
from .state import ProvisionError
|
||||
|
||||
|
||||
def _bundle_from_source(root):
|
||||
root = Path(root).resolve()
|
||||
package = root / 'provisioning'
|
||||
files = sorted(package.rglob('*.py'))
|
||||
seed = package / 'seed.sh'
|
||||
guard = package / 'profiles.sh'
|
||||
if not seed.is_file() or not (package / '__init__.py').is_file():
|
||||
raise ProvisionError('Provisioning package or seed is missing')
|
||||
files.append(seed)
|
||||
if guard.is_file():
|
||||
files.append(guard)
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode='w') as archive:
|
||||
for path in files:
|
||||
if (not path.is_file() or '__pycache__' in path.parts
|
||||
or any(part.is_symlink() for part in (path, *path.parents))):
|
||||
raise ProvisionError('Provisioning bundle contains an unsafe source path')
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(root).as_posix())
|
||||
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
|
||||
info.uid = info.gid = 0
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
data = output.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build_bundle(commons):
|
||||
root = Path(commons).resolve()
|
||||
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
|
||||
revision = 'unversioned-modified'
|
||||
if shutil.which('git'):
|
||||
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
|
||||
revision = result.stdout.strip()
|
||||
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node'], capture_output=True, text=True, timeout=30)
|
||||
if dirty.returncode or dirty.stdout.strip():
|
||||
revision += '-modified'
|
||||
return data, digest, revision
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Image-only installation and sanitization; never called by remote enrollment."""
|
||||
"""Image-only installation and sanitization; for golden-image builds."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -16,7 +16,7 @@ from .target import preflight, run
|
||||
|
||||
|
||||
def install():
|
||||
from .transport import _bundle_from_source
|
||||
from .bundle import _bundle_from_source
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
data, digest = _bundle_from_source(source)
|
||||
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
"""Workstation SSH transport. User-data stays opaque until target validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HandoverUncertain(TransportError):
|
||||
pass
|
||||
|
||||
|
||||
# This runs before Python is guaranteed to exist on the Debian target. All
|
||||
# ancestors must be trusted; a root-owned sticky temporary directory is safe
|
||||
# only because root owns each managed child and exclusive mktemp creates it.
|
||||
REMOTE_PATH_GUARD = r'''
|
||||
set -eu
|
||||
umask 077
|
||||
apex_guard_dir() {
|
||||
apex_checked=$1
|
||||
while :; do
|
||||
test ! -L "$apex_checked" && test -d "$apex_checked" || exit 91
|
||||
test "$(stat -c %u -- "$apex_checked")" = 0 || exit 91
|
||||
apex_mode=$(stat -c %a -- "$apex_checked")
|
||||
test "$((0$apex_mode & 0022))" = 0 || test "$((0$apex_mode & 01000))" != 0 || exit 91
|
||||
test "$apex_checked" != / || break
|
||||
apex_checked=${apex_checked%/*}
|
||||
test -n "$apex_checked" || apex_checked=/
|
||||
done
|
||||
}
|
||||
apex_ensure_dir() {
|
||||
apex_new_dir=$1
|
||||
if test -e "$apex_new_dir" || test -L "$apex_new_dir"; then
|
||||
apex_guard_dir "$apex_new_dir"
|
||||
apex_mode=$(stat -c %a -- "$apex_new_dir")
|
||||
test "$((0$apex_mode & 0022))" = 0 || exit 91
|
||||
else
|
||||
apex_guard_dir "${apex_new_dir%/*}"
|
||||
mkdir -m "$2" -- "$apex_new_dir"
|
||||
apex_guard_dir "$apex_new_dir"
|
||||
fi
|
||||
}
|
||||
apex_guard_file() {
|
||||
apex_file=$1
|
||||
apex_guard_dir "${apex_file%/*}"
|
||||
apex_mode=$(stat -c %a -- "${apex_file%/*}")
|
||||
test "$((0$apex_mode & 0022))" = 0 || exit 91
|
||||
test ! -L "$apex_file" || exit 91
|
||||
if test -e "$apex_file"; then
|
||||
test -f "$apex_file" || exit 91
|
||||
test "$(stat -c %u -- "$apex_file")" = 0 || exit 91
|
||||
test "$(stat -c %h -- "$apex_file")" = 1 || exit 91
|
||||
apex_mode=$(stat -c %a -- "$apex_file")
|
||||
test "$((0$apex_mode & 0022))" = 0 || exit 91
|
||||
fi
|
||||
}
|
||||
apex_atomic_input() {
|
||||
apex_destination=$1
|
||||
apex_guard_file "$apex_destination"
|
||||
apex_temporary=$(mktemp "${apex_destination%/*}/.apex-write.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$apex_temporary"' EXIT HUP INT TERM
|
||||
cat > "$apex_temporary"
|
||||
chmod 0600 "$apex_temporary"
|
||||
apex_guard_file "$apex_destination"
|
||||
mv -T -- "$apex_temporary" "$apex_destination"
|
||||
trap - EXIT HUP INT TERM
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def remote_atomic_input(path):
|
||||
return REMOTE_PATH_GUARD + '\napex_atomic_input ' + shlex.quote(path)
|
||||
|
||||
|
||||
def remote_bundle_install(stage, installed, digest):
|
||||
return REMOTE_PATH_GUARD + '\n' + (
|
||||
'apex_guard_dir ' + shlex.quote(stage) + '; apex_guard_file ' + shlex.quote(stage + '/bundle.tar') + '; '
|
||||
'test "$(sha256sum ' + shlex.quote(stage + '/bundle.tar') + ' | cut -d " " -f 1)" = ' + digest + '; '
|
||||
'apex_ensure_dir /usr/local/lib/apex-provisioner 0755; '
|
||||
'if test -e ' + installed + ' || test -L ' + installed + '; then '
|
||||
'apex_guard_dir ' + installed + '; '
|
||||
'apex_bad=$(find ' + installed + ' -xdev \\( -type l -o ! -user root -o -perm /022 -o \\( ! -type d -a ! -type f \\) \\) -print -quit); test -z "$apex_bad"; '
|
||||
'else apex_install_tmp=$(mktemp -d /usr/local/lib/apex-provisioner/.install.XXXXXXXXXX); '
|
||||
'trap \'rm -rf -- "$apex_install_tmp"\' EXIT HUP INT TERM; '
|
||||
'tar --no-same-owner --same-permissions -xf ' + stage + '/bundle.tar -C "$apex_install_tmp"; '
|
||||
'mv -T -- "$apex_install_tmp" ' + installed + '; trap - EXIT HUP INT TERM; fi; '
|
||||
'tar --compare -f ' + stage + '/bundle.tar -C ' + installed)
|
||||
|
||||
|
||||
def local_file(value, name):
|
||||
if not value or any(ord(c) < 32 for c in str(value)):
|
||||
raise TransportError(f'{name} must name a readable regular file')
|
||||
try:
|
||||
path = Path(value).expanduser().resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
raise TransportError(f'{name} must name a readable regular file') from None
|
||||
if not path.is_file() or not os.access(path, os.R_OK):
|
||||
raise TransportError(f'{name} must name a readable regular file')
|
||||
return path
|
||||
|
||||
|
||||
def destination(value, port=None):
|
||||
match = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_.-]{0,31})@([A-Za-z0-9][A-Za-z0-9_.:-]*|\[[0-9a-fA-F:]+\])', value or '')
|
||||
if not match or '..' in match[2]:
|
||||
raise TransportError('Target must be USER@HOST without SSH options; use root or an existing administrator with sudo -n')
|
||||
hostname = match[2].strip('[]')
|
||||
if ':' in hostname:
|
||||
try:
|
||||
ipaddress.IPv6Address(hostname)
|
||||
except ValueError:
|
||||
raise TransportError('IPv6 targets must contain a valid address; pass ports separately') from None
|
||||
if port is not None and (not re.fullmatch(r'[0-9]{1,5}', str(port)) or not 1 <= int(port) <= 65535):
|
||||
raise TransportError('Port must be an integer from 1 through 65535')
|
||||
return match[1], hostname, int(port) if port is not None else None
|
||||
|
||||
|
||||
def _bundle_from_source(root):
|
||||
root = Path(root).resolve()
|
||||
package = root / 'provisioning'
|
||||
files = sorted(package.rglob('*.py'))
|
||||
seed = package / 'seed.sh'
|
||||
if not seed.is_file() or not (package / '__init__.py').is_file():
|
||||
raise TransportError('Provisioning package or seed is missing')
|
||||
files.append(seed)
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode='w') as archive:
|
||||
for path in files:
|
||||
if (not path.is_file() or '__pycache__' in path.parts
|
||||
or any(part.is_symlink() for part in (path, *path.parents))):
|
||||
raise TransportError('Provisioning bundle contains an unsafe source path')
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(root).as_posix())
|
||||
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
|
||||
info.uid = info.gid = 0
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
data = output.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build_bundle(commons):
|
||||
root = Path(commons).resolve()
|
||||
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
|
||||
revision = 'unversioned-modified'
|
||||
if shutil.which('git'):
|
||||
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
|
||||
revision = result.stdout.strip()
|
||||
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node', 'ci/scripts/init-node.py'], capture_output=True, text=True, timeout=30)
|
||||
if dirty.returncode or dirty.stdout.strip():
|
||||
revision += '-modified'
|
||||
return data, digest, revision
|
||||
|
||||
|
||||
class SSH:
|
||||
def __init__(self, target, port=None, identity_file=None, runner=subprocess.run):
|
||||
self.user, self.host, self.port = destination(target, port)
|
||||
self.identity = local_file(identity_file, 'Identity file') if identity_file else None
|
||||
self.runner = runner
|
||||
self.resolved_host = None
|
||||
self.resolved_port = None
|
||||
self.trust_file = None
|
||||
|
||||
def arguments(self, fresh=False):
|
||||
args = ['ssh', '-T', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=15',
|
||||
'-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=3']
|
||||
if self.identity:
|
||||
args += ['-i', str(self.identity)]
|
||||
args += ['-l', 'adam' if fresh else self.user]
|
||||
if fresh:
|
||||
if not self.trust_file or not self.resolved_host:
|
||||
raise TransportError('Fresh access requires the verified bootstrap host identity')
|
||||
args += ['-p', '2222', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
|
||||
'-o', 'ControlPersist=no', '-o', 'BatchMode=yes',
|
||||
'-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no',
|
||||
'-o', 'KbdInteractiveAuthentication=no', '-o', 'Hostname=' + self.resolved_host,
|
||||
'-o', 'HostKeyAlias=apex-init-verified-target',
|
||||
'-o', 'UserKnownHostsFile=' + str(self.trust_file),
|
||||
'-o', 'GlobalKnownHostsFile=/dev/null']
|
||||
elif self.port:
|
||||
args += ['-p', str(self.port)]
|
||||
return args
|
||||
|
||||
def resolve(self):
|
||||
result = self.runner(self.arguments() + ['-G', '--', self.host], capture_output=True, text=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise TransportError('Unable to resolve SSH connection configuration')
|
||||
values = dict(line.split(' ', 1) for line in result.stdout.splitlines() if ' ' in line)
|
||||
hostname = values.get('hostname', '')
|
||||
port = values.get('port', '')
|
||||
destination(self.user + '@' + hostname, port)
|
||||
self.resolved_host, self.resolved_port = hostname, int(port)
|
||||
|
||||
def run(self, script, data=None, fresh=False, privileged=True, check=True, timeout=300):
|
||||
command = ['sh', '-c', script]
|
||||
if privileged and (fresh or self.user != 'root'):
|
||||
command = ['sudo', '-n', '--', *command]
|
||||
result = self.runner(self.arguments(fresh) + ['--', self.host, shlex.join(command)],
|
||||
input=data, capture_output=True, timeout=timeout)
|
||||
if check and result.returncode:
|
||||
# SSH and child diagnostics can contain user-data or credential-bearing URLs.
|
||||
raise TransportError('Remote operation failed; inspect the root-owned target status for a redacted diagnosis')
|
||||
return result
|
||||
|
||||
def fresh_login(self):
|
||||
self.run('test "$(id -u)" = 0', fresh=True)
|
||||
|
||||
|
||||
def protected_handover(ssh, invoke, token, port, client_address):
|
||||
result = invoke('begin', token, port, client_address, False)
|
||||
try:
|
||||
ssh.fresh_login()
|
||||
if result['status'] == 'committed':
|
||||
return
|
||||
if result['status'] != 'existing':
|
||||
invoke('finalize', token, None, None, True)
|
||||
ssh.fresh_login()
|
||||
try:
|
||||
invoke('commit', token, None, None, True)
|
||||
except Exception:
|
||||
try:
|
||||
state = invoke('status', token, None, None, True)
|
||||
if state.get('token') != token or state.get('status') not in ('committed', 'existing', 'transition', 'final', 'rolled_back', 'recovery_pending'):
|
||||
raise TransportError('Unexpected access transition state')
|
||||
except Exception:
|
||||
raise HandoverUncertain('Access commit outcome is unknown; verify the administrator and bootstrap endpoints before resuming') from None
|
||||
if state['status'] != 'committed':
|
||||
raise
|
||||
except HandoverUncertain:
|
||||
raise
|
||||
except Exception:
|
||||
try:
|
||||
invoke('rollback', token, None, None, True)
|
||||
except Exception:
|
||||
try:
|
||||
invoke('rollback', token, None, None, False)
|
||||
except Exception: # noqa: S110 — diagnostics may contain secrets.
|
||||
pass # Persistent timer is authoritative if both connections are gone.
|
||||
raise
|
||||
|
||||
|
||||
def initialize(commons, target, user_data, port=None, identity_file=None, progress=print):
|
||||
if not shutil.which('ssh'):
|
||||
raise TransportError('OpenSSH ssh is required on the workstation')
|
||||
source = local_file(user_data, 'User-data')
|
||||
if source.stat().st_size > 1024 * 1024:
|
||||
raise TransportError('User-data must be no larger than 1 MiB')
|
||||
ssh = SSH(target, port, identity_file)
|
||||
ssh.resolve()
|
||||
bundle, digest, revision = build_bundle(commons)
|
||||
run_id = uuid.uuid4().hex
|
||||
installed = '/usr/local/lib/apex-provisioner/' + digest
|
||||
stage = None
|
||||
launched = False
|
||||
finished = False
|
||||
access_committed = False
|
||||
access_uncertain = False
|
||||
cleanup_policy = '/etc/tmpfiles.d/apex-init-' + run_id + '.conf'
|
||||
with tempfile.TemporaryDirectory(prefix='apex-init-trust-') as local:
|
||||
try:
|
||||
preflight = ssh.run('set -eu; test "$(id -u)" = 0; . /etc/os-release; '
|
||||
'test "$ID" = debian; test "$VERSION_ID" = 13; '
|
||||
'test "$(dpkg --print-architecture)" = amd64; '
|
||||
'command -v systemd-run >/dev/null; '
|
||||
'printf "%s\\n" "${SSH_CONNECTION:-}"; '
|
||||
'cat /etc/ssh/ssh_host_*_key.pub')
|
||||
lines = preflight.stdout.decode().splitlines()
|
||||
connection = lines[0].split() if lines else []
|
||||
if len(connection) != 4:
|
||||
# sudo commonly clears SSH_CONNECTION; read it before elevation instead.
|
||||
raw = ssh.run('printf "%s\\n" "$SSH_CONNECTION"', privileged=False).stdout.decode().split()
|
||||
connection = raw
|
||||
if len(connection) != 4:
|
||||
raise TransportError('Unable to inspect the established SSH connection')
|
||||
keys = [line for line in lines[1:] if re.fullmatch(r'(ssh-[\w-]+|ecdsa-[\w-]+) [A-Za-z0-9+/=]+(?: .*?)?', line)]
|
||||
if not keys:
|
||||
raise TransportError('Unable to obtain public host keys through the verified connection')
|
||||
trust = Path(local) / 'known_hosts'
|
||||
trust.write_text(''.join('apex-init-verified-target,[apex-init-verified-target]:2222 ' + ' '.join(key.split()[:2]) + '\n' for key in keys))
|
||||
trust.chmod(0o600)
|
||||
ssh.trust_file = trust
|
||||
existing = ssh.run(REMOTE_PATH_GUARD + '\napex_ensure_dir /var/lib/apex-init 0700; '
|
||||
'apex_guard_file /var/lib/apex-init/remote.json; '
|
||||
'if test -f /var/lib/apex-init/remote.json; then cat /var/lib/apex-init/remote.json; fi').stdout
|
||||
if existing:
|
||||
prior = json.loads(existing)
|
||||
previous_unit = prior.get('unit', '')
|
||||
if not re.fullmatch(r'apex-init-[a-f0-9]{32}', previous_unit):
|
||||
raise TransportError('Existing remote coordinator state requires manual inspection')
|
||||
active = ssh.run(shlex.join(['systemctl', 'is-active', '--quiet', previous_unit]), check=False)
|
||||
if active.returncode == 0:
|
||||
raise TransportError('An enrollment unit is still running: ' + previous_unit + '; wait for it to settle before resuming')
|
||||
stage = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_dir /var/tmp; mktemp -d /var/tmp/apex-init.XXXXXXXXXX').stdout.decode().strip()
|
||||
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage): # noqa: S108 — validates remote mktemp output.
|
||||
raise TransportError('Target returned an invalid staging path')
|
||||
# Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs.
|
||||
cleanup = ('r ' + stage + '/user-data - - - -\n' +
|
||||
'r ' + stage + '/heartbeat - - - -\n').encode()
|
||||
ssh.run(REMOTE_PATH_GUARD + '\napex_ensure_dir /etc/tmpfiles.d 0755; apex_atomic_input ' + cleanup_policy, cleanup)
|
||||
ssh.run(remote_atomic_input(stage + '/bundle.tar'), bundle)
|
||||
ssh.run(remote_atomic_input(stage + '/user-data'), source.read_bytes())
|
||||
install = remote_bundle_install(stage, installed, digest)
|
||||
progress('Installing the versioned target runtime and validating enrollment input.')
|
||||
ssh.run(install)
|
||||
base = ['python3', '-m', 'provisioning.target']
|
||||
options = ['--user-data', stage + '/user-data', '--revision', revision, '--bundle-digest', digest]
|
||||
# State remains inspectable after disconnect; user-data is removed by the durable unit.
|
||||
enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat'])
|
||||
validation = shlex.join(base + ['validate'] + options)
|
||||
access_preflight = shlex.join(['python3', '-m', 'provisioning.access', 'preflight'])
|
||||
seed = shlex.join(['bash', installed + '/provisioning/seed.sh'])
|
||||
wrapper = ('umask 077; cd ' + installed + '; '
|
||||
'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; '
|
||||
'{ ' + seed + ' && ' + validation + ' && ' + access_preflight + ' && ' + enrollment + '; } >' + stage + '/runner.log 2>&1; rc=$?; '
|
||||
'printf "%s\\n" "$rc" >' + stage + '/result.new; mv ' + stage + '/result.new ' + stage + '/result; exit "$rc"')
|
||||
unit = 'apex-init-' + run_id
|
||||
record = json.dumps({'unit': unit, 'staging': stage, 'revision': revision, 'bundle_digest': digest}).encode()
|
||||
ssh.run(remote_atomic_input('/var/lib/apex-init/remote.json'), record)
|
||||
ssh.run('touch ' + stage + '/heartbeat; ' + shlex.join(['systemd-run', '--quiet', '--unit', unit,
|
||||
'--property=Type=exec', '--property=KillMode=process', '/bin/sh', '-c', wrapper]))
|
||||
launched = True
|
||||
progress('Enrollment is running under ' + unit + '; target staging: ' + stage)
|
||||
while True:
|
||||
status = ssh.run('if test -f ' + stage + '/result; then cat ' + stage + '/result; '
|
||||
'elif systemctl is-active --quiet ' + unit + '; then touch ' + stage + '/heartbeat; printf running; '
|
||||
'else printf stopped; fi').stdout.decode().strip()
|
||||
if status != 'running':
|
||||
finished = True
|
||||
if status != '0':
|
||||
raise TransportError('Target provisioning failed; inspect root-owned ' + stage +
|
||||
'/runner.log and /var/lib/apex-init/enrollment.json')
|
||||
break
|
||||
time.sleep(5)
|
||||
progress('Enrollment verified. Checking protected administrator SSH handover.')
|
||||
|
||||
def access(operation, token, original_port, address, fresh):
|
||||
if operation == 'status':
|
||||
result = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_file /var/lib/apex-init/access/state.json; '
|
||||
'cat /var/lib/apex-init/access/state.json', fresh=True)
|
||||
return json.loads(result.stdout)
|
||||
args = ['python3', '-m', 'provisioning.access', operation, '--token', token]
|
||||
if original_port:
|
||||
args += ['--original-port', str(original_port), '--client-address', address]
|
||||
result = ssh.run('cd ' + installed + ' && ' + shlex.join(args), fresh=fresh)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
protected_handover(ssh, access, run_id, int(connection[3]), connection[0])
|
||||
access_committed = True
|
||||
result = ssh.run('cd ' + installed + ' && ' + shlex.join(base + ['complete']), fresh=True)
|
||||
completed = json.loads(result.stdout)
|
||||
progress('Initialization and fresh administrator access verified; provisioner ' + revision + ', bundle ' + digest)
|
||||
repository = completed.get('stages', {}).get('repository', {}).get('result', {})
|
||||
if repository.get('commit'):
|
||||
progress('Repository ' + repository['commit'] + '; commons ' + repository.get('commons_commit', 'unknown'))
|
||||
return completed
|
||||
except HandoverUncertain:
|
||||
access_uncertain = True
|
||||
raise
|
||||
finally:
|
||||
if stage and (not launched or finished):
|
||||
for fresh in (True, False):
|
||||
try:
|
||||
ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh)
|
||||
break
|
||||
except Exception: # noqa: S110 — cleanup tries both verified endpoints.
|
||||
pass
|
||||
if launched and not finished:
|
||||
progress('Coordinator stopped. The current target stage may finish; later stages stop after heartbeat expiry.')
|
||||
if access_uncertain:
|
||||
progress('Access commit outcome is unknown; verify which endpoint is active before resuming. Rollback may already be disabled.')
|
||||
endpoints = [('Administrator candidate', 'adam', 2222), ('Bootstrap candidate', ssh.user, ssh.resolved_port)]
|
||||
else:
|
||||
label = 'Safe resume' + ('' if access_committed else ' after any pending rollback')
|
||||
endpoints = [(label, 'adam' if access_committed else ssh.user, 2222 if access_committed else ssh.resolved_port)]
|
||||
for label, resume_user, resume_port in endpoints:
|
||||
resume = ['python3', 'ci/scripts/init-node.py', resume_user + '@' + ssh.host,
|
||||
'--port', str(resume_port), '--user-data', str(source)]
|
||||
if ssh.identity:
|
||||
resume += ['--identity-file', str(ssh.identity)]
|
||||
progress(label + ': ' + shlex.join(resume))
|
||||
Reference in new issue
Block a user