#!/bin/sh # EdgeOne Provisioning bootstrapper (ADR 0001, layer 1). POSIX sh. # # Detects /etc/os-release and `dpkg --print-architecture`, enforces the OS # matrix, adds the Microsoft and BlackWell apt repositories with signed-by # keyrings (each key's fingerprint is checked against the embedded one before # any keyring is written) and installs edgeonectl. It never touches # device identity during installation and never enables backports. Safe to re-run. # --uninstall (ADR 0019, scope of 0.2.1) resets the local identity, stops aziot # and the check-in timer, removes the edgeonectl package and removes the apt # sources and keyrings this script wrote, only while they are unchanged. # # Usage and exit codes: usage() below (sh install.sh --help). set -eu # --- Release-time constants -------------------------------------------------- # Rendered by scripts/render-install-sh.sh. While they hold the @...@ # placeholders the script refuses to run (exit 78). The BlackWell host, suite # and key are assigned by blackwell-infra (ADR 0001, "Hosting"). BLACKWELL_APT_URL='https://packages.dev.getblackwell.systems' BLACKWELL_SUITE='stable/' BLACKWELL_KEY_FPR='9E5EC3C7C9061FFE2CC2C8F10A60707D60913E73' # Under the base URL: the armored repository key and this script's signature. # Part of the consumer contract fixed by amendment once the host is assigned. BLACKWELL_KEY_FILE='archive-key.asc' BLACKWELL_SIG_FILE='install.sh.asc' # --- Microsoft (packages.microsoft.com) ----------------------------------------- # Layout and keys verified 2026-10-17 against # https://learn.microsoft.com/en-us/linux/packages (key fingerprints, config URIs) # https://packages.microsoft.com/config/debian/12/prod.list # https://packages.microsoft.com/config/ubuntu/24.04/prod.list # ("deb [arch=amd64,arm64,armhf signed-by=/usr/share/keyrings/microsoft-prod.gpg] # https://packages.microsoft.com///prod main"); the # Debian 12 and Ubuntu 24.04 Release.gpg are issued by EB3E94ADBE1229CF. # microsoft.asc signs both of them. Microsoft publishes no IoT Edge for the # preview OSes (Debian 13, Ubuntu 26.04), which use the repository of their # Tier 1 sibling (owner D4, 2026-10-27), so no other key is needed. MICROSOFT_URL='https://packages.microsoft.com' MICROSOFT_KEY_FPR='BC528686B50D79E339D3721CEB3E94ADBE1229CF' PACKAGE=edgeonectl KEYRING_DIR=/etc/apt/keyrings SOURCES_DIR=/etc/apt/sources.list.d BLACKWELL_KEYRING=$KEYRING_DIR/blackwell-archive-keyring.gpg BLACKWELL_SOURCES=$SOURCES_DIR/blackwell.sources OS_RELEASE=/etc/os-release UPDATE_ATTEMPTS=4 # Never fails: a closed stderr must not turn a documented exit code into 1. log() { printf 'install.sh: %s\n' "$*" >&2 || :; } die() { rc=$1 shift log "$*" exit "$rc" } # A heredoc, not this file's comments, so --help also works under # `curl ... | sh -s -- --help`, where there is no file to read. usage() { cat <<'EOF' Usage: sh install.sh [options] Installs edgeonectl from the signed BlackWell and Microsoft apt repositories on Debian 12 or Ubuntu 24.04 (amd64, arm64). Never touches device identity, never enables backports. Safe to re-run. Run as root. Options: --version VER install edgeonectl version VER; an older VER than the installed one is a deliberate downgrade (rollback) --mirror URL BlackWell repository base URL; keys are still checked against the embedded fingerprints --microsoft-mirror URL packages.microsoft.com base URL; same check --allow-http-mirror accept a plain-http mirror URL (keys and install.sh.asc then travel over http too; the fingerprints still bind) --allow-unsupported-os accept the preview OSes (Debian 13, Ubuntu 26.04); Microsoft packages come from Debian 12 / Ubuntu 24.04; forwarded to edgeonectl provision with --provision --lock-timeout SECONDS total budget for waiting on the dpkg/apt locks over the whole run, not per wait (default 120) --non-interactive never prompt (the installer itself never does); excludes --provision --verify-only check this file against install.sh.asc with the embedded BlackWell key and exit (a convenience: it is not a trust anchor for a script of unknown origin) --provision after installing, exec `edgeonectl provision`. Needs a terminal on stdout. Token input, forwarded unread: --token-file PATH read by edgeonectl --token-stdin stdin must be a pipe, not a terminal; run install.sh from a file (under `curl | sh`, stdin carries the script) (neither) hidden prompt; stdin must be a terminal also forwarded: --environment ENV, --allow-unsupported-os --uninstall run `edgeonectl reset -y`, stop and disable aziot and the check-in timer, apt-get remove edgeonectl (not purge) and remove the apt sources and keyrings install.sh wrote while unchanged (a modified one is preserved). Keeps IoT Edge, moby-engine, containers and /etc, /var data (see --purge, --remove-engine). Reports, never removes, the legacy edgeone-provisioning.sources and keyring. Does not retire or revoke the device in Fleet. Takes the same --mirror, --microsoft-mirror and --allow-http-mirror as the install did; also --lock-timeout and --non-interactive --purge with --uninstall: also run `edgeonectl cleanup --scope purge -y` (Docker objects, module trees, module users and the binding that edgeonectl created; anything else is preserved and reported), remove /etc/tmpfiles.d/edgeone-modules.conf and finally `dpkg --purge edgeonectl`. Needs the installation inventory (docs/runbooks/pre-inventory-host.md) --remove-engine with --uninstall: also run `edgeonectl cleanup --scope remove-engine -y` and remove aziot-edge, aziot-identity-service and moby-engine when the inventory says install.sh installed them (preexisting ones are preserved). Needs the inventory -y, --yes with --uninstall: skip typing uninstall to confirm; mandatory without a terminal on stdin or with --non-interactive -h, --help this help Environment: EDGEONE_PROVISIONING_MIRROR as --mirror EDGEONE_PROVISIONING_MICROSOFT_MIRROR as --microsoft-mirror EDGEONE_PROVISIONING_LOCK_TIMEOUT as --lock-timeout EDGEONE_PROVISIONING_ALLOW_HTTP_MIRROR 1 as --allow-http-mirror (0 or 1) Flags override the environment. Exit codes (sysexits): 0 success (also: pinned --version already installed, nothing to do) 64 usage: unknown or malformed option; token flags without --provision; --uninstall without confirmation or with an install-only option; --purge or --remove-engine without --uninstall; --provision without a terminal on stdout, with --token-stdin on a terminal or under `curl | sh`, or with the prompt and no terminal on stdin; plain-http mirror without --allow-http-mirror 69 unavailable: download, apt-get update/install, or a local operation (temporary directory, writing keyrings, sources, the installation inventory or journal) failed; docker did not start after install.sh installed moby-engine; also an internal error (an invalid inventory/journal field is refused before anything is written) 75 temporary: the dpkg/apt lock or the edgeonectl run lock (/var/lib/edgeone/provisioning/lock) stayed held past --lock-timeout; re-run 77 not root 78 configuration (operator action needed, retrying will not help): /var/lib/edgeone, its provisioning directory, the run lock, inventory or journal is a symlink, not the expected file type, or has a foreign header; unsupported OS or architecture; unrendered or tampered install.sh; a repository key with the wrong fingerprint; a Microsoft or BlackWell apt source not written by install.sh; docker.io/docker-ce/docker-ee/ docker-engine installed (moby-engine replaces them); moby-engine would remove packages; --purge/--remove-engine without an installation inventory (pre-inventory host) or while the journal holds an open operation of another scope; install while an uninstall, purge or remove-engine operation is open (finish it with --uninstall first); half-configured dpkg (run dpkg --configure -a, then re-run install.sh) EOF } usage_error() { log "$*" log "see: sh install.sh --help" exit 64 } TMP= cleanup() { [ -z "$TMP" ] || rm -rf -- "$TMP"; } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM # --- Arguments --------------------------------------------------------------- version= mirror=${EDGEONE_PROVISIONING_MIRROR:-} ms_mirror=${EDGEONE_PROVISIONING_MICROSOFT_MIRROR:-} lock_timeout=${EDGEONE_PROVISIONING_LOCK_TIMEOUT:-120} allow_unsupported=0 verify_only=0 provision=0 non_interactive=0 uninstall=0 purge=0 remove_engine=0 confirm_uninstall=0 case ${EDGEONE_PROVISIONING_ALLOW_HTTP_MIRROR:-0} in 0) allow_http=0 ;; 1) allow_http=1 ;; *) usage_error "EDGEONE_PROVISIONING_ALLOW_HTTP_MIRROR must be 0 or 1" ;; esac token_inputs=0 token_mode=prompt # Arguments forwarded to `edgeonectl provision` are rotated back into "$@". n=$# while [ "$n" -gt 0 ]; do arg=$1 shift n=$((n - 1)) case $arg in -h | --help) usage exit 0 ;; --version | --mirror | --microsoft-mirror | --lock-timeout | --token-file | --environment) [ "$n" -gt 0 ] || usage_error "$arg needs a value" val=$1 shift n=$((n - 1)) case $arg in --version) version=$val ;; --mirror) mirror=$val ;; --microsoft-mirror) ms_mirror=$val ;; --lock-timeout) lock_timeout=$val ;; *) token_inputs=1 [ "$arg" != --token-file ] || token_mode='file' set -- "$@" "$arg" "$val" ;; esac ;; --token-stdin) token_inputs=1 token_mode=stdin set -- "$@" "$arg" ;; --allow-unsupported-os) allow_unsupported=1 set -- "$@" "$arg" ;; --allow-http-mirror) allow_http=1 ;; --verify-only) verify_only=1 ;; --provision) provision=1 ;; --non-interactive) non_interactive=1 ;; --uninstall) uninstall=1 ;; --purge) purge=1 ;; --remove-engine) remove_engine=1 ;; -y | --yes) confirm_uninstall=1 ;; *) usage_error "unknown argument: $arg" ;; esac done if [ "$uninstall" = 0 ] && [ "$confirm_uninstall" = 1 ]; then usage_error "-y requires --uninstall" fi if [ "$uninstall" = 0 ] && { [ "$purge" = 1 ] || [ "$remove_engine" = 1 ]; }; then usage_error "--purge and --remove-engine require --uninstall" fi if [ "$uninstall" = 1 ]; then [ "$provision$verify_only$token_inputs" = 000 ] && [ -z "$version" ] || usage_error "--uninstall excludes --provision, --verify-only, --version and token/environment options" fi case $lock_timeout in '' | *[!0-9]*) usage_error "--lock-timeout must be a number of seconds" ;; esac case $version in *[!0-9A-Za-z.+~:-]*) usage_error "invalid --version '$version'" ;; esac for url in "$mirror" "$ms_mirror"; do case $url in '' | http://?* | https://?*) ;; *) usage_error "mirror must be an http(s) URL: $url" ;; esac case $url in *[!A-Za-z0-9.:/_~%@+-]*) usage_error "invalid characters in mirror URL: $url" ;; esac done if [ "$token_inputs" = 1 ] && [ "$provision" = 0 ]; then usage_error "--token-file, --token-stdin and --environment are only forwarded with --provision" fi if [ "$provision" = 1 ]; then [ "$non_interactive" = 0 ] || usage_error "--provision is interactive; drop --non-interactive" [ "$verify_only" = 0 ] || usage_error "--provision and --verify-only exclude each other" # P5: the terminal is for edgeonectl's output; stdin depends on the token # input (edgeonectl refuses --token-stdin on a terminal, where it would echo). [ -t 1 ] || usage_error "--provision needs a terminal on stdout; run edgeonectl provision directly" case $token_mode in stdin) [ ! -t 0 ] || usage_error "--token-stdin reads a pipe, not a terminal: pipe the token or use --token-file / the prompt" # Under `curl | sh` stdin carries the script itself, not the token. [ -f "$0" ] || usage_error "--token-stdin needs install.sh run from a file (sh install.sh ...); use --token-file or the prompt" ;; prompt) [ -t 0 ] || usage_error "--provision without --token-file or --token-stdin prompts for the token and needs a terminal on stdin" ;; esac fi # --- dpkg/apt lock, dpkg state, apt-get --------------------------------------------- # A lock is held when /proc/locks lists the inode of one of the lock files # (apt and dpkg take fcntl locks, which flock(1) cannot see). lock_held() { for f in /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock \ /var/cache/apt/archives/lock; do [ -e "$f" ] || continue ino=$(stat -c %i "$f") || continue if awk -v ino="$ino" '{ for (i = 1; i <= NF; i++) if ($i ~ /^[0-9a-f]+:[0-9a-f]+:[0-9]+$/) { n = split($i, a, ":"); if (a[n] == ino) found = 1 } } END { exit !found }' /proc/locks; then return 0 fi done return 1 } # --lock-timeout bounds the total time spent waiting for locks over the whole # run (before every apt attempt and inside apt), not each wait. lock_waited=0 lock_budget_exhausted() { die 75 "the dpkg/apt lock is still held after ${lock_timeout}s (another apt or unattended-upgrades run); re-run later" } wait_for_lock() { start=$(date +%s) logged=0 while lock_held; do [ $((lock_waited + $(date +%s) - start)) -lt "$lock_timeout" ] || lock_budget_exhausted [ "$logged" = 1 ] || log "waiting up to $((lock_timeout - lock_waited))s for the dpkg/apt lock" logged=1 sleep 1 done lock_waited=$((lock_waited + $(date +%s) - start)) } apt_get() { DEBIAN_FRONTEND=noninteractive apt-get -q \ -o DPkg::Lock::Timeout="$((lock_timeout - lock_waited))" "$@" 9>&- } # Runs apt-get "$@" into $TMP/apt.log, waiting for the locks first. When apt # itself loses the race for a lock ("Could not get lock"), the time is charged # to the lock budget and the run starts again; it does not count as one of the # caller's attempts. Returns non-zero for any other failure. apt_try() { while :; do wait_for_lock t0=$(date +%s) if apt_get "$@" >"$TMP/apt.log" 2>&1 && ! grep -q '^E:' "$TMP/apt.log"; then return 0 fi grep -q 'Could not get lock' "$TMP/apt.log" || return 1 sleep 1 lock_waited=$((lock_waited + $(date +%s) - t0)) [ "$lock_waited" -lt "$lock_timeout" ] || lock_budget_exhausted log "apt-get $1: a dpkg/apt lock was taken by another process; waiting again" done } apt_failed() { tail -n 20 "$TMP/apt.log" >&2 || : if grep -q 'Could not get lock' "$TMP/apt.log"; then die 75 "$1: the dpkg/apt lock is held; re-run later" fi die 69 "$1 failed" } apt_update() { attempt=1 delay=2 while :; do if apt_try update; then return 0 fi [ "$attempt" -lt "$UPDATE_ATTEMPTS" ] || apt_failed "apt-get update ($attempt attempts)" log "apt-get update failed (attempt $attempt/$UPDATE_ATTEMPTS); retrying in ${delay}s" sleep "$delay" attempt=$((attempt + 1)) delay=$((delay * 2)) done } # --- Release configuration --------------------------------------------------- case $BLACKWELL_APT_URL$BLACKWELL_SUITE$BLACKWELL_KEY_FPR in *@*) die 78 "this install.sh is not configured for release (unrendered placeholders); use the published, signed install.sh" ;; esac case $BLACKWELL_KEY_FPR in *[!0-9A-F]*) die 78 "invalid embedded BlackWell key fingerprint" ;; esac [ "${#BLACKWELL_KEY_FPR}" -eq 40 ] || die 78 "invalid embedded BlackWell key fingerprint" case $BLACKWELL_SUITE in */) ;; *) die 78 "BlackWell suite must be a flat-repository path ending in /" ;; esac bw_base=${mirror:-$BLACKWELL_APT_URL} bw_base=${bw_base%/} ms_base=${ms_mirror:-$MICROSOFT_URL} ms_base=${ms_base%/} # Plain http only on request (P6): apt still checks the signed-by keyrings, but # the keys and install.sh.asc are fetched over the same transport. for url in "$bw_base" "$ms_base"; do case $url in http://*) [ "$allow_http" = 1 ] || usage_error "refusing plain-http repository URL $url; pass --allow-http-mirror (or EDGEONE_PROVISIONING_ALLOW_HTTP_MIRROR=1) to accept it" ;; esac done curl_proto='=https' [ "$allow_http" = 0 ] || curl_proto='=https,http' TMP=$(mktemp -d) || die 69 "cannot create a temporary directory (mktemp -d)" mkdir -m 0700 "$TMP/gnupg" || die 69 "cannot create $TMP/gnupg" GNUPGHOME=$TMP/gnupg export GNUPGHOME need() { for tool in "$@"; do command -v "$tool" >/dev/null 2>&1 || return 1 done } # Only keys and signatures are fetched here (small files): 30 s per try. fetch() { curl -fsSL --proto "$curl_proto" --proto-redir "$curl_proto" --connect-timeout 20 \ --max-time 30 --retry 3 -o "$2" "$1" || die 69 "cannot download $1" } # Prints the fingerprint of the only primary key in armored file $1; fails # unless the file holds exactly one primary key. key_fpr() { gpg --batch --quiet --show-keys --with-colons "$1" 2>/dev/null | awk -F: ' $1 == "pub" { n++; p = 1; next } p && $1 == "fpr" { f = $10 } { p = 0 } END { if (n != 1) exit 1; print f }' } # Downloads key $1, checks it against fingerprint $2 and leaves the binary # keyring in $3. Nothing outside $TMP is written before the check passes. fetch_verified_key() { fetch "$1" "$TMP/key.asc" got=$(key_fpr "$TMP/key.asc") || got= [ "$got" = "$2" ] || die 78 "key $1 has fingerprint ${got:-}, expected $2; refusing to use it" rm -f -- "$3" || die 69 "cannot remove $3" gpg --batch --quiet --dearmor -o "$3" "$TMP/key.asc" || die 78 "cannot dearmor $1" } # Renders the two apt sources for this host into $TMP. The install writes # them; --uninstall compares against them, so both use these exact bytes. render_sources() { cat >"$TMP/microsoft.sources" <"$TMP/blackwell.sources" <= 8.24), rename, fsync # of the directory. Every field is validated before a write; an invalid field # is an internal error (69) and nothing is written. STATE_ROOT=/var/lib/edgeone PROV_DIR=/var/lib/edgeone/provisioning INVENTORY=$PROV_DIR/inventory.tsv JOURNAL=$PROV_DIR/journal.tsv RUN_LOCK=$PROV_DIR/lock STATE_JSON=$PROV_DIR/state.json TAB=$(printf '\t') NL=' ' recording=0 op_id= op_scope= lock_held_by_us=0 field_ok() { case $2 in *"$TAB"* | *"$NL"* | '') return 1 ;; esac printf '%s\n' "$2" | grep -Eqx -- "$1" } need_field() { field_ok "$2" "$3" || die 69 "internal error: invalid inventory/journal $1 '$3'" } now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } # Validates kind ($1), root ($2) and rel ($3); "-" allowed when $4 = journal. need_identity() { if [ "${4:-}" = journal ] && [ "$1$2$3" = --- ]; then return 0; fi need_field kind 'file|dir|apt-source|keyring|package|user|group|docker-network|docker-volume|binding' "$1" need_field root 'etc_edgeone|var_lib_edgeone|var_cache_edgeone|run_edgeone|apt_sources|apt_keyrings|none' "$2" need_field rel '[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*' "$3" case /$3/ in */../*) die 69 "internal error: invalid inventory/journal rel '$3'" ;; esac if [ "$3" = . ] && [ "$1" != dir ]; then die 69 "internal error: rel . is only for kind dir"; fi } # Moves complete temp file $1 over $2 (0600) durably; removes $1 on failure. commit_file() { if chmod 0600 "$1" && sync -- "$1" && mv -f -- "$1" "$2" && sync -- "${2%/*}"; then return 0 fi rm -f -- "$1" || : die 69 "cannot write $2" } new_temp() { mktemp "$PROV_DIR/.$1.XXXXXX" || die 69 "cannot create a temporary file in $PROV_DIR"; } need_header() { [ "$(head -n 1 "$1")" = "$2" ] || die 78 "$1 does not start with the expected header ($2); refusing to rewrite it" } inv_init() { [ "$recording" = 1 ] || return 0 for f in "$INVENTORY:#edgeone-inventory" "$JOURNAL:#edgeone-journal"; do path=${f%%:*} if [ -L "$path" ] || { [ -e "$path" ] && [ ! -f "$path" ]; }; then die 78 "$path is not a regular file; remove it by hand" fi if [ -e "$path" ]; then need_header "$path" "${f#*:}${TAB}1" continue fi tmp=$(new_temp init) printf '%s\t1\n' "${f#*:}" >"$tmp" || { rm -f -- "$tmp" die 69 "cannot write $tmp" } commit_file "$tmp" "$path" done } # inv_upsert KIND ROOT REL ORIGIN STATUS SHA256 OWNER MODE REF: replaces the # row with the same (kind, root, rel). The first origin sticks: a resource # recorded as created stays created; preexisting becomes modified when written. inv_upsert() { [ "$recording" = 1 ] || return 0 need_identity "$1" "$2" "$3" need_field origin 'created|preexisting|modified' "$4" need_field status 'present|removed' "$5" need_field sha256 '[0-9a-f]{64}|-' "$6" need_field owner '[0-9]+:[0-9]+|-' "$7" need_field mode '[0-7]{4}|-' "$8" # {1,256} as + and a length check: BSD regex caps repetition at 255. need_field ref '[A-Za-z0-9._:/@+=~-]+|-' "$9" [ "${#9}" -le 256 ] || die 69 "internal error: inventory ref longer than 256 characters" at=$(now_utc) need_field recorded_at '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$at" need_header "$INVENTORY" "#edgeone-inventory${TAB}1" prior=$(awk -F '\t' -v k="$1" -v r="$2" -v p="$3" \ '!/^#/ && $1 == k && $2 == r && $3 == p && $5 == "present" { print $4 }' "$INVENTORY") row_origin=$4 case $prior/$4 in created/*) row_origin=created ;; modified/*) row_origin=modified ;; preexisting/created) row_origin=preexisting ;; esac tmp=$(new_temp inventory) if ! { awk -F '\t' -v k="$1" -v r="$2" -v p="$3" '/^#/ || !($1 == k && $2 == r && $3 == p)' "$INVENTORY" && printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$1" "$2" "$3" "$row_origin" "$5" "$6" "$7" "$8" "$9" install.sh "$at" } >"$tmp"; then rm -f -- "$tmp" die 69 "cannot write $tmp" fi commit_file "$tmp" "$INVENTORY" } # inv_mark_removed KIND ROOT REL: sets status removed on an existing row. inv_mark_removed() { [ "$recording" = 1 ] || return 0 need_identity "$1" "$2" "$3" row=$(awk -F '\t' -v k="$1" -v r="$2" -v p="$3" '!/^#/ && $1 == k && $2 == r && $3 == p' "$INVENTORY") [ -n "$row" ] || return 0 # shellcheck disable=SC2046 # fields hold no whitespace (validated on write) set -- $(printf '%s\n' "$row" | tr '\t' ' ') inv_upsert "$1" "$2" "$3" "$4" removed "$6" "$7" "$8" "$9" } # jrn_line SCOPE STEP PHASE KIND ROOT REL: appends one line. op-end compacts # the operation's lines into that single line. jrn_line() { [ "$recording" = 1 ] || return 0 need_field op_id '[0-9a-f]{16}' "$op_id" need_field scope 'install|uninstall|purge|remove-engine' "$1" need_field step '[a-z][a-z0-9-]{0,63}' "$2" need_field phase 'op-begin|intent|done|op-end' "$3" need_identity "$4" "$5" "$6" journal at=$(now_utc) need_header "$JOURNAL" "#edgeone-journal${TAB}1" seq=$(awk -F '\t' '!/^#/ && $1 + 0 > m { m = $1 + 0 } END { print m + 1 }' "$JOURNAL") need_field seq '[1-9][0-9]*' "$seq" tmp=$(new_temp journal) if ! { if [ "$3" = op-end ]; then awk -F '\t' -v id="$op_id" '/^#/ || $2 != id' "$JOURNAL" else cat -- "$JOURNAL" fi && printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$seq" "$op_id" "$1" "$2" "$3" "$4" "$5" "$6" "$at" } >"$tmp"; then rm -f -- "$tmp" die 69 "cannot write $tmp" fi commit_file "$tmp" "$JOURNAL" } jrn_begin() { [ "$recording" = 1 ] || return 0 op_scope=$1 op_id=$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n') || op_id= jrn_line "$op_scope" begin op-begin - - - } # find_open_op [install]: sets open_id/open_scope to the first uninstall-family # operation (uninstall, purge, remove-engine) of the journal that has no op-end # line; with install, to the first open install operation instead. find_open_op() { open_id= open_scope= [ -f "$JOURNAL" ] || return 0 found=$(awk -F '\t' -v want="${1:-}" '!/^#/ && $3 != "binding" && (want == "install" ? $3 == "install" : $3 != "install") { if ($5 == "op-end") { fin[$2] = 1 } else if (!($2 in sc)) { sc[$2] = $3; ord[++n] = $2 } } END { for (i = 1; i <= n; i++) if (!(ord[i] in fin)) { print ord[i], sc[ord[i]]; exit } }' "$JOURNAL") || die 69 "cannot read $JOURNAL" [ -z "$found" ] || { open_id=${found% *} open_scope=${found#* } } } jrn_intent() { jrn_line "$op_scope" "$1" intent "$2" "$3" "$4"; } jrn_done() { jrn_line "$op_scope" "$1" 'done' "$2" "$3" "$4"; } jrn_end() { [ "$recording" = 1 ] || return 0 jrn_line "$op_scope" finish op-end - - - } # resumed_intent KIND ROOT REL: true when this run resumed an operation that # already holds an intent for the target, i.e. the crashed run was about to # create it. On a recording host nothing of ours exists without a row before # that, so the target is ours (created), not preexisting. resumed=0 resumed_intent() { [ "$resumed" = 1 ] && awk -F '\t' -v id="$op_id" -v k="$1" -v r="$2" -v rel="$3" \ '$2 == id && $5 == "intent" && $6 == k && $7 == r && $8 == rel { f = 1 } END { exit !f }' "$JOURNAL" } # Prints "uid:gid mode" of path $1 (mode as 4 octal digits) from ls -ldn. path_meta() { # shellcheck disable=SC2012 # one known path; ls -n is the POSIX owner/mode source ls -ldn -- "$1" | awk '{ p = $1; s = 0; o = "" for (i = 0; i < 3; i++) { d = 0 if (substr(p, 2 + 3 * i, 1) == "r") d += 4 if (substr(p, 3 + 3 * i, 1) == "w") d += 2 c = substr(p, 4 + 3 * i, 1) if (c == "x" || c == "s" || c == "t") d += 1 if (c == "s" || c == "S") s += (i == 0 ? 4 : 2) if (c == "t" || c == "T") s += 1 o = o d } print $3 ":" $4, s o }' } # inv_file KIND ROOT REL PATH ORIGIN: records regular file PATH as present. inv_file() { [ "$recording" = 1 ] || return 0 sum=$(sha256sum -- "$4" | awk '{ print $1 }') || die 69 "cannot hash $4" meta=$(path_meta "$4") || die 69 "cannot stat $4" inv_upsert "$1" "$2" "$3" "$5" present "$sum" "${meta% *}" "${meta#* }" - } inv_dir() { [ "$recording" = 1 ] || return 0 # inv_dir ROOT REL PATH ORIGIN meta=$(path_meta "$3") || die 69 "cannot stat $3" inv_upsert dir "$1" "$2" "$4" present - "${meta% *}" "${meta#* }" - } # Creates directory $1 (root:root, mode $2) or repairs an existing one, as # packaging/debian/postinst does; a symlink or non-directory is refused (78). # Sets dir_origin to created or preexisting. ensure_state_dir() { if [ -L "$1" ] || { [ -e "$1" ] && [ ! -d "$1" ]; }; then die 78 "$1 exists and is not a directory; remove it by hand and re-run install.sh" fi dir_origin=preexisting if [ ! -d "$1" ]; then mkdir -m "$2" -- "$1" || die 69 "cannot create $1" dir_origin=created fi chown root:root -- "$1" || die 69 "cannot chown $1" chmod "$2" -- "$1" || die 69 "cannot chmod $1" } # The run lock edgeonectl takes (flock(2) LOCK_EX on $RUN_LOCK, ADR 0009), # held on descriptor 9. Children never inherit it (9>&- at each call site # that matters: apt, edgeonectl). Busy past --lock-timeout: 75. run_lock_take() { if [ -L "$RUN_LOCK" ] || { [ -e "$RUN_LOCK" ] && [ ! -f "$RUN_LOCK" ]; }; then die 78 "$RUN_LOCK is not a regular file; remove it by hand" fi (umask 077 && : >>"$RUN_LOCK") || die 69 "cannot create $RUN_LOCK" exec 9>>"$RUN_LOCK" || die 69 "cannot open $RUN_LOCK" if flock -E 75 -w "$lock_timeout" 9; then lock_held_by_us=1 return 0 else rc=$? fi exec 9>&- [ "$rc" != 75 ] || die 75 "the edgeonectl run lock $RUN_LOCK is still held after ${lock_timeout}s; re-run later" die 69 "cannot lock $RUN_LOCK" } run_lock_release() { [ "$lock_held_by_us" = 1 ] || return 0 exec 9>&- lock_held_by_us=0 } # --- --verify-only ------------------------------------------------------------- if [ "$verify_only" = 1 ]; then self=$0 [ -f "$self" ] || usage_error "--verify-only needs the script as a file: sh install.sh --verify-only" need curl gpg gpgv || die 69 "--verify-only needs curl, gpg and gpgv (apt-get install curl gpg gpgv)" fetch_verified_key "$bw_base/$BLACKWELL_KEY_FILE" "$BLACKWELL_KEY_FPR" "$TMP/blackwell.gpg" sig=$self.asc if [ ! -f "$sig" ]; then sig=$TMP/install.sh.asc fetch "$bw_base/$BLACKWELL_SIG_FILE" "$sig" fi if gpgv --keyring "$TMP/blackwell.gpg" "$sig" "$self" >"$TMP/gpgv.log" 2>&1; then log "OK: $self is signed by the BlackWell key $BLACKWELL_KEY_FPR ($sig)" exit 0 fi cat "$TMP/gpgv.log" >&2 || : die 78 "signature check FAILED for $self; do not run it" fi # --- Preconditions ------------------------------------------------------------- # --uninstall confirms first and checks root itself (uninstall_device). [ "$uninstall" = 1 ] || [ "$(id -u)" -eq 0 ] || die 77 "must run as root (sudo sh install.sh)" [ -r "$OS_RELEASE" ] || OS_RELEASE=/usr/lib/os-release [ -r "$OS_RELEASE" ] || die 78 "cannot read /etc/os-release" # shellcheck disable=SC1090 # os-release(5) is meant to be sourced os_field() { (. "$OS_RELEASE" && eval "printf '%s' \"\${$1:-}\""); } os_id=$(os_field ID) || die 78 "cannot parse $OS_RELEASE" os_ver=$(os_field VERSION_ID) || die 78 "cannot parse $OS_RELEASE" os_codename=$(os_field VERSION_CODENAME) || die 78 "cannot parse $OS_RELEASE" command -v dpkg >/dev/null 2>&1 || die 78 "dpkg not found: only Debian and Ubuntu are supported" arch=$(dpkg --print-architecture) || die 69 "dpkg --print-architecture failed" ms_key_file=microsoft.asc ms_key_fpr=$MICROSOFT_KEY_FPR # The Microsoft repository path and suite; a preview OS takes its sibling's. ms_os_ver=$os_ver ms_codename=$os_codename case $os_id/$os_ver in debian/12 | ubuntu/24.04) ;; debian/13 | ubuntu/26.04) # Removal needs no opt-in: the install already required it. [ "$allow_unsupported" = 1 ] || [ "$uninstall" = 1 ] || die 78 "$os_id $os_ver is a preview OS; re-run with --allow-unsupported-os to accept it" if [ "$os_id" = debian ]; then ms_os_ver=12 ms_codename=bookworm else ms_os_ver=24.04 ms_codename=noble fi [ "$uninstall" = 1 ] || log "preview OS $os_id $os_ver accepted (--allow-unsupported-os); Microsoft packages from $os_id $ms_os_ver ($ms_codename)" ;; *) die 78 "unsupported OS ${os_id:-?} ${os_ver:-?}: Debian 12 and Ubuntu 24.04 are supported" ;; esac case $arch in amd64 | arm64) ;; *) die 78 "unsupported architecture $arch (amd64, arm64)" ;; esac [ -n "$os_codename" ] || die 78 "VERSION_CODENAME missing from $OS_RELEASE" case $os_codename in *[!a-z0-9]*) die 78 "invalid VERSION_CODENAME '$os_codename'" ;; esac # --- --uninstall (ADR 0019, scope of 0.2.1) -------------------------------------- # No downloads on the removal path. The operator verifies this script before # running it, as for installation. Identity data, IoT Edge, moby-engine, # containers and their networks are never touched here (ADR 0019, C2). # Removes keyring $2 unless it is not the regular file install.sh writes, does # not hold exactly the one primary key with fingerprint $1, or another apt # source still references it. Any doubt preserves it. remove_owned_keyring() { [ -e "$2" ] || [ -L "$2" ] || return 0 if [ -L "$2" ] || [ ! -f "$2" ]; then log "preserved keyring (not a regular file): $2" return 0 fi if ! command -v gpg >/dev/null 2>&1; then log "preserved keyring (gpg not found, fingerprint not checked): $2" return 0 fi got=$(key_fpr "$2") || got= if [ "$got" != "$1" ]; then log "preserved keyring (fingerprint ${got:-}, expected $1): $2" return 0 fi for f in /etc/apt/sources.list "$SOURCES_DIR"/*.list "$SOURCES_DIR"/*.sources; do [ -e "$f" ] || continue if grep -qF "$2" "$f"; then log "preserved keyring (still referenced by $f): $2" return 0 fi done jrn_intent remove-keyring keyring apt_keyrings "${2##*/}" rm -f -- "$2" || die 69 "cannot remove $2" log "removed: $2" inv_mark_removed keyring apt_keyrings "${2##*/}" jrn_done remove-keyring keyring apt_keyrings "${2##*/}" } # Removes source $1 when it is the regular file install.sh renders for this # host, byte for byte ($2), then its keyring $3 (fingerprint $4). The rendered # source names that keyring by its exact Signed-By path, so an unchanged source # is the evidence that install.sh wrote the keyring too. remove_owned_source() { if [ ! -e "$1" ] && [ ! -L "$1" ]; then # Gone because a killed run of ours removed it (row removed): its keyring # is still queued, else a resumed uninstall would orphan it. if [ "$recording" = 1 ] && [ -f "$INVENTORY" ] && awk -F '\t' -v rel="${1##*/}" '$1 == "apt-source" && $2 == "apt_sources" && $3 == rel && $5 == "removed" { f = 1 } END { exit !f }' "$INVENTORY"; then removed_keyrings="$removed_keyrings $4:$3" fi return 0 fi if [ -L "$1" ] || [ ! -f "$1" ] || ! head -n 1 "$1" | grep -qxF "$(head -n 1 "$2")" || ! cmp -s "$1" "$2"; then log "preserved modified source: $1" return 0 fi jrn_intent remove-source apt-source apt_sources "${1##*/}" rm -f -- "$1" || die 69 "cannot remove $1" log "removed: $1" inv_mark_removed apt-source apt_sources "${1##*/}" jrn_done remove-source apt-source apt_sources "${1##*/}" removed_keyrings="$removed_keyrings $4:$3" } # The first line edgeonectl provision writes into the tmpfiles.d file # (RenderModuleTmpfiles); only a file starting with it is ours to remove. TMPFILES_CONF=/etc/tmpfiles.d/edgeone-modules.conf TMPFILES_HEADER='# Generated by edgeonectl provision from the BlackWell module registry' remove_tmpfiles_conf() { [ -e "$TMPFILES_CONF" ] || [ -L "$TMPFILES_CONF" ] || return 0 if [ -L "$TMPFILES_CONF" ] || [ ! -f "$TMPFILES_CONF" ] || [ "$(head -n 1 "$TMPFILES_CONF")" != "$TMPFILES_HEADER" ]; then log "preserved: $TMPFILES_CONF (not written by edgeonectl)" return 0 fi jrn_intent remove-tmpfiles - - - rm -f -- "$TMPFILES_CONF" || die 69 "cannot remove $TMPFILES_CONF" log "removed: $TMPFILES_CONF" jrn_done remove-tmpfiles - - - } # Removes engine package $1 only when its inventory row says install.sh # installed it (origin created); anything else is preserved and reported. remove_engine_package() { row=$(awk -F '\t' -v p="$1" '!/^#/ && $1 == "package" && $2 == "none" && $3 == p { print $4 " " $5 }' "$INVENTORY") case $row in '') log "preserved: $1 (not inventoried)" return 0 ;; 'created present') ;; 'created removed') log "unchanged: $1 already removed" return 0 ;; *) log "preserved: $1 (preexisting)" return 0 ;; esac case $(dpkg-query -W -f '${db:Status-Abbrev}' "$1" 2>/dev/null) in ?[iUFHWt]*) jrn_intent remove-package package none "$1" apt_try remove -y "$1" || apt_failed "apt-get remove $1" log "removed: $1" ;; *) log "unchanged: $1 is not installed" ;; esac inv_mark_removed package none "$1" jrn_done remove-package package none "$1" } # Cleanup runs before edgeonectl is removed: an intent to remove the package # in the resumed operation proves cleanup completed, and so does an # install.sh operation of the same scope closed last before this run (a power # cut after jrn_end and before dpkg --purge leaves exactly that). cleanup_already_ran() { [ -f "$JOURNAL" ] && awk -F '\t' -v id="$op_id" -v pkg="$PACKAGE" -v scope="$un_scope" ' $2 == id && $4 == "remove-package" && $8 == pkg { f = 1 } $2 != id && $5 == "op-end" { last = ($4 == "finish") ? $3 : "" } END { exit !(f || last == scope) }' "$JOURNAL" } # op_has_steps ID: true when the journal holds an intent or done line for ID. op_has_steps() { [ -f "$JOURNAL" ] && awk -F '\t' -v id="$1" '$2 == id && ($5 == "intent" || $5 == "done") { f = 1 } END { exit !f }' "$JOURNAL" } # close_abandoned_op: closes the open uninstall-family operation (open_id, # open_scope) when it holds only its op-begin line, i.e. no step ever started. # Such an operation (left by a refused purge) must not block an install. close_abandoned_op() { [ -n "$open_id" ] || return 0 op_has_steps "$open_id" && return 0 op_id=$open_id op_scope=$open_scope jrn_end log "closed abandoned $open_scope operation $open_id (no step started)" } uninstall_device() { if [ "$confirm_uninstall" = 0 ]; then [ "$non_interactive" = 0 ] || usage_error "--uninstall with --non-interactive needs -y" [ -t 0 ] || usage_error "--uninstall needs a terminal or -y" printf 'Type uninstall to continue: ' >&2 IFS= read -r answer || usage_error "confirmation was not received" [ "$answer" = uninstall ] || usage_error "confirmation must be uninstall" fi [ "$(id -u)" -eq 0 ] || die 77 "must run as root (sudo sh install.sh --uninstall)" un_scope=uninstall [ "$remove_engine" = 0 ] || un_scope=remove-engine [ "$purge" = 0 ] || un_scope=purge if [ "$un_scope" != uninstall ] && { [ -L "$INVENTORY" ] || [ ! -f "$INVENTORY" ] || [ -L "$PROV_DIR" ]; }; then die 78 "no installation inventory ($INVENTORY): --purge and --remove-engine cannot tell what edgeonectl created; see docs/runbooks/pre-inventory-host.md" fi log "uninstall resets the local identity and removes $PACKAGE; it does not retire or revoke the device in Fleet" # Lock scheme: the run lock is held for the unit stops, released for # `edgeonectl reset -y` (which takes it itself; holding it would make reset # exit 75) and taken again for apt and the sources. With the timer stopped, # only an operator-started edgeonectl can slip into the two short windows; # reset then exits 75 (propagated) or the later steps simply wait. if [ -d "$PROV_DIR" ] && [ ! -L "$PROV_DIR" ]; then run_lock_take [ ! -f "$INVENTORY" ] || recording=1 inv_init find_open_op if [ -z "$open_id" ]; then # Refuse before opening an operation that could never finish. if [ "$un_scope" != uninstall ] && ! command -v edgeonectl >/dev/null 2>&1 && ! cleanup_already_ran; then die 78 "edgeonectl is not installed, so cleanup cannot run; install it again (sh install.sh), then re-run this command" fi jrn_begin "$un_scope" elif [ "$open_scope" = "$un_scope" ]; then op_id=$open_id op_scope=$open_scope log "resuming the open $open_scope operation $open_id" else die 78 "journal operation $open_id ($open_scope) is still open; finish it with its own --uninstall flags before running $un_scope" fi fi wait_for_lock audit=$(dpkg --audit 2>&1) || die 69 "dpkg --audit failed" [ -z "$audit" ] || die 78 "dpkg has unfinished packages; run dpkg --configure -a first" # Stop the timer first so it cannot race identity deletion. A known unit # failing to stop is an error; an absent unit is harmless on a repeat run. if [ -d /run/systemd/system ]; then for unit in edgeonectl-check-in.timer edgeonectl-check-in.service \ aziot-edged.service aziot-identityd.service aziot-certd.service aziot-keyd.service aziot-tpmd.service \ aziot-edged.socket aziot-identityd.socket aziot-certd.socket aziot-keyd.socket aziot-tpmd.socket; do load=$(systemctl show -p LoadState --value "$unit") || die 69 "cannot query $unit" [ "$load" != not-found ] || continue [ -n "$load" ] || die 69 "empty LoadState for $unit" systemctl stop "$unit" || die 69 "cannot stop $unit" case $unit in aziot-*) systemctl disable "$unit" || die 69 "cannot disable $unit" ;; esac done fi if command -v edgeonectl >/dev/null 2>&1; then had_lock=$lock_held_by_us run_lock_release if edgeonectl reset -y 9>&-; then :; else rc=$? die "$rc" "edgeonectl reset failed; packages and apt sources preserved" fi if [ "$un_scope" != uninstall ]; then if edgeonectl cleanup --scope "$un_scope" -y 9>&-; then :; else rc=$? die "$rc" "edgeonectl cleanup failed; packages and apt sources preserved" fi fi [ "$had_lock" = 0 ] || run_lock_take elif [ "$un_scope" != uninstall ] && ! cleanup_already_ran; then close_abandoned_op die 78 "edgeonectl is not installed, so cleanup cannot run; install it again (sh install.sh), then re-run this command" fi status=$(dpkg-query -W -f '${db:Status-Abbrev}' "$PACKAGE" 2>/dev/null) || status= case $status in ?[iUFHWt]*) jrn_intent remove-package package none "$PACKAGE" apt_try remove -y "$PACKAGE" || apt_failed "apt-get remove $PACKAGE" inv_mark_removed package none "$PACKAGE" jrn_done remove-package package none "$PACKAGE" ;; *) log "unchanged: $PACKAGE is not installed" ;; esac if [ "$remove_engine" = 1 ]; then remove_engine_package aziot-edge remove_engine_package aziot-identity-service remove_engine_package moby-engine fi render_sources removed_keyrings= remove_owned_source "$SOURCES_DIR/microsoft-prod.sources" "$TMP/microsoft.sources" \ "$KEYRING_DIR/microsoft-prod.gpg" "$ms_key_fpr" remove_owned_source "$BLACKWELL_SOURCES" "$TMP/blackwell.sources" "$BLACKWELL_KEYRING" "$BLACKWELL_KEY_FPR" # Keyrings after both sources, so one source cannot keep the other's alive. for entry in $removed_keyrings; do remove_owned_keyring "${entry%%:*}" "${entry#*:}" done for f in "$SOURCES_DIR/edgeone-provisioning.sources" /usr/share/keyrings/edgeone-provisioning.gpg; do if [ -e "$f" ] || [ -L "$f" ]; then log "preserved legacy $f (not written by this installer; remove it by hand)"; fi done [ "$un_scope" = uninstall ] || remove_tmpfiles_conf jrn_end run_lock_release if [ "$purge" = 1 ]; then case $(dpkg-query -W -f '${db:Status-Abbrev}' "$PACKAGE" 2>/dev/null) in r*) wait_for_lock dpkg --purge "$PACKAGE" || die 69 "dpkg --purge $PACKAGE failed" ;; esac fi log "uninstall completed; retiring the device in Fleet remains an operator action" } if [ "$uninstall" = 1 ]; then uninstall_device exit 0 fi # --- Prior state (P2): refuse rather than clobber ---------------------------------- # Prints the URIs of the apt sources in file $1, one per line, from one-line # (sources.list(5)) and deb822 entries. source_uris() { awk ' /^[[:space:]]*#/ { next } $1 == "deb" || $1 == "deb-src" { i = 2 if ($i ~ /^\[/) { while (i <= NF && $i !~ /\]$/) i++; i++ } if (i <= NF) print $i next } tolower($1) == "uris:" { for (i = 2; i <= NF; i++) print $i } ' "$1" 2>/dev/null } # Drops the scheme (also mirror+/tor+ forms) and trailing slashes. norm_uri() { printf '%s\n' "$1" | sed -e 's|^[A-Za-z0-9+.-]*://||' -e 's|/*$||'; } ms_repo=$(norm_uri "$MICROSOFT_URL/$os_id/$ms_os_ver/prod") ms_repo_mirror=$(norm_uri "$ms_base/$os_id/$ms_os_ver/prod") bw_repo=$(norm_uri "$BLACKWELL_APT_URL") bw_repo_mirror=$(norm_uri "$bw_base") own_header='# Written by EdgeOne Provisioning install.sh.' for f in /etc/apt/sources.list "$SOURCES_DIR"/*.list "$SOURCES_DIR"/*.sources; do [ -f "$f" ] || continue case $f in "$SOURCES_DIR/microsoft-prod.sources" | "$BLACKWELL_SOURCES") # Ours by name; refuse a same-named file written by something else. head -n 1 "$f" | grep -qF "$own_header" || die 78 "$f exists but was not written by install.sh; remove it and re-run install.sh" continue ;; esac for uri in $(source_uris "$f"); do u=$(norm_uri "$uri") for r in "$ms_repo" "$ms_repo_mirror"; do case $u in "$r" | "$r"/*) die 78 "an existing Microsoft apt source is configured in $f (for example by packages-microsoft-prod); remove it and re-run install.sh" ;; esac done for r in "$bw_repo" "$bw_repo_mirror"; do case $u in "$r" | "$r"/*) die 78 "an existing BlackWell apt source is configured in $f; remove it and re-run install.sh" ;; esac done done done # moby-engine Conflicts with these; apt would remove them and stop their # containers. That is the operator's call, never ours. for pkg in docker.io docker-ce docker-ee docker-engine; do [ "$(dpkg-query -W -f '${Status}' "$pkg" 2>/dev/null)" = "install ok installed" ] || continue die 78 "moby-engine replaces $pkg; IoT Edge needs moby-engine. Remove $pkg deliberately (containers will stop) and re-run install.sh" done wait_for_lock # P3: an operator action, not retryable, so 78 (not 75). audit=$(dpkg --audit 2>&1) || : if [ -n "$audit" ]; then printf '%s\n' "$audit" >&2 || : die 78 "dpkg has half-configured or unfinished packages; run dpkg --configure -a, then re-run install.sh" fi # --- State directory, run lock, inventory (ADR 0019) -------------------------------- # A host installed before the inventory existed gets none (owner D5): its # resources cannot be attributed, so purge needs a clean reinstall. pkg_status() { dpkg-query -W -f '${db:Status-Abbrev}' "$1" 2>/dev/null || :; } if [ -f "$INVENTORY" ]; then recording=1 elif [ "$(pkg_status "$PACKAGE")" = "ii " ] || [ -e "$STATE_JSON" ]; then log "pre-inventory host: no installation inventory; purge needs a clean reinstall" else recording=1 fi find_open_op if [ -n "$open_id" ] && [ "$recording" = 1 ] && ! op_has_steps "$open_id"; then run_lock_take find_open_op # re-read under the lock: a concurrent uninstall may have moved on close_abandoned_op run_lock_release find_open_op fi [ -z "$open_id" ] || die 78 "the $open_scope operation $open_id is still open; finish it with sh install.sh --uninstall (same flags) before installing" ensure_state_dir "$STATE_ROOT" 0755 root_origin=$dir_origin ensure_state_dir "$PROV_DIR" 0700 prov_origin=$dir_origin run_lock_take inv_init # A killed install leaves its operation open: resume it (same op_id) so its # op-end compacts the crashed run's lines too. find_open_op install if [ -n "$open_id" ]; then op_id=$open_id op_scope=install resumed=1 log "resuming the open install operation $open_id" else jrn_begin install fi jrn_intent ensure-dir dir var_lib_edgeone . inv_dir var_lib_edgeone . "$STATE_ROOT" "$root_origin" inv_dir var_lib_edgeone provisioning "$PROV_DIR" "$prov_origin" jrn_done ensure-dir dir var_lib_edgeone . # Package origins are taken before any apt transaction of this run. preinstalled=' ' # A package the resumed operation was installing is ours. aziot-edge and # aziot-identity-service come in with edgeonectl and have no intent of their # own: after a crash during that transaction they stay preexisting (kept). for pkg in moby-engine "$PACKAGE" aziot-edge aziot-identity-service; do [ "$(pkg_status "$pkg")" != "ii " ] || resumed_intent package none "$pkg" || preinstalled="$preinstalled$pkg " done if ! need curl gpg || [ ! -f /etc/ssl/certs/ca-certificates.crt ]; then log "installing prerequisites from the distribution: ca-certificates curl gpg" apt_update apt_try install -y --no-install-recommends ca-certificates curl gpg || apt_failed "apt-get install ca-certificates curl gpg" fi # --- Repositories ------------------------------------------------------------------ fetch_verified_key "$bw_base/$BLACKWELL_KEY_FILE" "$BLACKWELL_KEY_FPR" "$TMP/blackwell.gpg" fetch_verified_key "$ms_base/keys/$ms_key_file" "$ms_key_fpr" "$TMP/microsoft.gpg" # sources.list(5): /etc/apt/keyrings is for keyrings managed by the operator # or scripts; created 0755 when missing (an existing directory is left as is). for entry in "apt_sources:$SOURCES_DIR" "apt_keyrings:$KEYRING_DIR"; do dir=${entry#*:} dorigin=preexisting ! resumed_intent dir "${entry%%:*}" . || dorigin=created if [ ! -d "$dir" ]; then jrn_intent create-dir dir "${entry%%:*}" . mkdir -m 0755 "$dir" || die 69 "cannot create $dir" dorigin=created fi inv_dir "${entry%%:*}" . "$dir" "$dorigin" [ "$dorigin" = preexisting ] || jrn_done create-dir dir "${entry%%:*}" . done # write_owned KIND ROOT SRC DST: write_if_changed with journal and inventory. write_owned() { jrn_intent "write-$1" "$1" "$2" "${4##*/}" write_if_changed "$3" "$4" 0644 origin=$(wic_origin) [ "$origin" != preexisting ] || ! resumed_intent "$1" "$2" "${4##*/}" || origin=created inv_file "$1" "$2" "${4##*/}" "$4" "$origin" jrn_done "write-$1" "$1" "$2" "${4##*/}" } write_owned keyring apt_keyrings "$TMP/blackwell.gpg" "$BLACKWELL_KEYRING" write_owned keyring apt_keyrings "$TMP/microsoft.gpg" "$KEYRING_DIR/microsoft-prod.gpg" render_sources write_owned apt-source apt_sources "$TMP/microsoft.sources" "$SOURCES_DIR/microsoft-prod.sources" write_owned apt-source apt_sources "$TMP/blackwell.sources" "$BLACKWELL_SOURCES" # --- Install ------------------------------------------------------------------------- installed=$(dpkg-query -W -f '${db:Status-Abbrev} ${Version}' "$PACKAGE" 2>/dev/null) || installed= if [ -n "$version" ] && [ "$changed" = 0 ] && [ "$installed" = "ii $version" ]; then # Pinned version already installed and repositories unchanged (PERF-2). log "$PACKAGE $version already installed; nothing to do" else apt_update # Container engine first, in its own transaction. The aziot-edge preinst exits # 1 ("No container runtime detected") unless the docker group exists, and # aziot-edge does not Depend on moby-engine, so a single # `apt-get install edgeonectl` can unpack aziot-edge before # moby-engine and leave dpkg half-configured. This follows Microsoft's # documented order ("Install IoT Edge": "Install a container engine" with # `apt-get install moby-engine`, then "Install the IoT Edge runtime" with # `apt-get install aziot-edge`; verified 2026-10-18 against # https://learn.microsoft.com/en-us/azure/iot-edge/how-to-provision-single-device-linux-symmetric # ms.date 2026-02-26). Not Pre-Depends, which Debian Policy discourages. if [ "$(dpkg-query -W -f '${db:Status-Abbrev}' moby-engine 2>/dev/null)" = "ii " ]; then log "unchanged: moby-engine already installed" else # Belt and braces for P2: never let this transaction remove a package. apt_try install -s -y moby-engine || apt_failed "apt-get install -s moby-engine" removals=$(sed -n 's/^Remv \([^ ]*\).*/\1/p' "$TMP/apt.log" | tr '\n' ' ') [ -z "$removals" ] || die 78 "installing moby-engine would remove: ${removals% }; remove them deliberately (containers will stop) and re-run install.sh" log "installing moby-engine (container engine, before IoT Edge)" jrn_intent install-package package none moby-engine apt_try install -y moby-engine || apt_failed "apt-get install moby-engine" jrn_done install-package package none moby-engine fi # apt's pkg=ver pin stays internal; operators read "edgeonectl 0.1.0". target=$PACKAGE${version:+=$version} label="$PACKAGE${version:+ $version}" log "installing $label" jrn_intent install-package package none "$PACKAGE" if [ -n "$version" ]; then # REL-10a: an explicit --version may be older than the installed one (a # rollback); apt-get -y refuses that without --allow-downgrades. apt_try install -y --allow-downgrades "$target" || apt_failed "installing $label" else apt_try install -y "$target" || apt_failed "installing $label" fi jrn_done install-package package none "$PACKAGE" log "installed: $(edgeonectl --version 2>/dev/null || echo "$PACKAGE")" fi # Packages: origin from before this run's apt transactions, ref = version. for pkg in moby-engine "$PACKAGE" aziot-edge aziot-identity-service; do ver=$(dpkg-query -W -f '${db:Status-Abbrev}${Version}' "$pkg" 2>/dev/null) || ver= case $ver in "ii "?*) ;; *) continue ;; esac case $preinstalled in *" $pkg "*) porigin=preexisting ;; *) porigin=created ;; esac inv_upsert package none "$pkg" "$porigin" present - - - "${ver#ii }" done moby_origin=created case $preinstalled in *" moby-engine "*) moby_origin=preexisting ;; esac if [ "$recording" = 1 ]; then moby_origin=$(awk -F '\t' '!/^#/ && $1 == "package" && $2 == "none" && $3 == "moby-engine" && $5 == "present" { print $4 }' "$INVENTORY") fi jrn_end run_lock_release # moby-engine's postinst starts docker.socket and docker.service before it # creates the docker group (SocketGroup=docker), so on a fresh host both fail # until the next boot (Microsoft's debian12 builds 29.3.1-29.9.2, found in the # C2 lab). Start them when the engine is ours, also on a re-run; a preexisting # engine is the operator's, and `edgeonectl check` reports it. if [ "$moby_origin" = created ] && [ -d /run/systemd/system ] && ! { systemctl is-active --quiet docker.socket && systemctl is-active --quiet docker.service; }; then log "starting docker.socket and docker.service" systemctl reset-failed docker.socket docker.service 2>/dev/null || : systemctl start docker.socket docker.service || die 69 "docker did not start; see systemctl status docker.socket docker.service" fi if [ "$provision" = 1 ]; then command -v edgeonectl >/dev/null 2>&1 || die 69 "edgeonectl not found after installing $PACKAGE" cleanup trap - EXIT exec edgeonectl provision "$@" fi log "next: sudo edgeonectl check && sudo edgeonectl provision"