@ouro.bot/cli 0.1.0-alpha.797 → 0.1.0-alpha.799

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.
@@ -1,10 +1,6 @@
1
1
  Mendelow Cloud Butler operator runbook
2
2
 
3
- The production container is ouro-butler. It runs as UID/GID 10001, publishes no
4
- ports, uses host networking only so its loopback-only Unraid GraphQL client can
5
- reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write plus
6
- the privileged event spool and SAB configuration read-only, and uses restart
7
- policy unless-stopped.
3
+ The production container is ouro-butler. It runs as UID/GID 10001, publishes no ports, uses host networking only so its loopback-only Unraid GraphQL client can reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write plus the privileged event spool read-only, and uses restart policy unless-stopped.
8
4
 
9
5
  Start/stop:
10
6
  docker start ouro-butler
@@ -26,12 +22,18 @@ Effective-spec audit helper:
26
22
  AUDIT_EXPECTED_IMAGE=$2
27
23
  AUDIT_RUNNER_IMAGE_ID=$3
28
24
  AUDIT_MOUNT_CONTRACT=${4-canonical}
25
+ AUDIT_EXPECTED_IMAGE_REFERENCE=${5-}
26
+ AUDIT_EXPECTED_ICON=${6-}
29
27
  validate_exact_image_id "$AUDIT_EXPECTED_IMAGE" || return $?
30
28
  validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID" || return $?
31
29
  test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
32
30
  case "$AUDIT_MOUNT_CONTRACT" in
33
- canonical) set -- ;;
34
- legacy-alpha742) set -- --mount-contract legacy-alpha742 ;;
31
+ canonical)
32
+ test -n "$AUDIT_EXPECTED_IMAGE_REFERENCE" || return 1
33
+ test "$AUDIT_EXPECTED_ICON" = https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png || return 1
34
+ set -- --expected-image-reference "$AUDIT_EXPECTED_IMAGE_REFERENCE" --expected-icon "$AUDIT_EXPECTED_ICON"
35
+ ;;
36
+ legacy-alpha742|prepackage-alpha797) set -- --mount-contract "$AUDIT_MOUNT_CONTRACT" ;;
35
37
  *) return 1 ;;
36
38
  esac
37
39
  INSPECT_DIR=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/inspect.XXXXXX) || return $?
@@ -133,7 +135,7 @@ Effective-spec audit helper:
133
135
  set_butler_autostart() {
134
136
  (
135
137
  AUTOSTART_PROFILE=$1
136
- case "$AUTOSTART_PROFILE" in disabled|production) ;; *) return 1 ;; esac
138
+ case "$AUTOSTART_PROFILE" in disabled|production|staging) ;; *) return 1 ;; esac
137
139
  test "$(id -u)" -eq 0 || return $?
138
140
  AUTOSTART_FILE=/var/lib/docker/unraid-autostart
139
141
  AUTOSTART_INCLUDE=/usr/local/emhttp/plugins/dynamix.docker.manager/include/UpdateConfig.php
@@ -145,9 +147,10 @@ Effective-spec audit helper:
145
147
  set -- $AUTOSTART_COUNTS
146
148
  test "$#" -eq 4 || return $?
147
149
  for AUTOSTART_COUNT in "$@"; do case "$AUTOSTART_COUNT" in 0|1) ;; *) return 1 ;; esac; done
148
- if test "$AUTOSTART_PROFILE" = production; then
149
- docker container inspect ouro-butler >/dev/null 2>&1 || return $?
150
- fi
150
+ case "$AUTOSTART_PROFILE" in
151
+ production) docker container inspect ouro-butler >/dev/null 2>&1 || return $? ;;
152
+ staging) docker container inspect ouro-butler-staging >/dev/null 2>&1 || return $? ;;
153
+ esac
151
154
  AUTOSTART_BACKUP=$(mktemp /var/lib/docker/.unraid-autostart.ouro.XXXXXX) || return $?
152
155
  AUTOSTART_NONBUTLER_BEFORE=$(mktemp /run/unraid-autostart.nonbutler-before.XXXXXX) || { rm -f -- "$AUTOSTART_BACKUP"; return 1; }
153
156
  AUTOSTART_NONBUTLER_AFTER=$(mktemp /run/unraid-autostart.nonbutler-after.XXXXXX) || { rm -f -- "$AUTOSTART_BACKUP" "$AUTOSTART_NONBUTLER_BEFORE"; return 1; }
@@ -189,16 +192,21 @@ Effective-spec audit helper:
189
192
  sync -f /var/lib/docker || return $?
190
193
  AUTOSTART_BACKUP_READY=yes
191
194
  snapshot_nonbutler_autostart >"$AUTOSTART_NONBUTLER_BEFORE" || return $?
192
- mutate_butler_autostart ouro-butler-staging false || return $?
193
- mutate_butler_autostart ouro-butler-rollback false || return $?
194
- mutate_butler_autostart ouro-butler-legacy-evidence false || return $?
195
- if test "$AUTOSTART_PROFILE" = production; then
196
- mutate_butler_autostart ouro-butler true || return $?
197
- AUTOSTART_EXPECTED_COUNTS="1 0 0 0"
198
- else
199
- mutate_butler_autostart ouro-butler false || return $?
200
- AUTOSTART_EXPECTED_COUNTS="0 0 0 0"
201
- fi
195
+ case "$AUTOSTART_PROFILE" in
196
+ production) AUTOSTART_TARGET=ouro-butler ;;
197
+ staging) AUTOSTART_TARGET=ouro-butler-staging ;;
198
+ disabled) AUTOSTART_TARGET=- ;;
199
+ esac
200
+ for AUTOSTART_CONTAINER in ouro-butler ouro-butler-staging ouro-butler-rollback ouro-butler-legacy-evidence; do
201
+ test "$AUTOSTART_CONTAINER" = "$AUTOSTART_TARGET" && continue
202
+ mutate_butler_autostart "$AUTOSTART_CONTAINER" false || return $?
203
+ done
204
+ test "$AUTOSTART_TARGET" = - || mutate_butler_autostart "$AUTOSTART_TARGET" true || return $?
205
+ case "$AUTOSTART_PROFILE" in
206
+ production) AUTOSTART_EXPECTED_COUNTS="1 0 0 0" ;;
207
+ staging) AUTOSTART_EXPECTED_COUNTS="0 1 0 0" ;;
208
+ disabled) AUTOSTART_EXPECTED_COUNTS="0 0 0 0" ;;
209
+ esac
202
210
  verify_butler_autostart "$AUTOSTART_EXPECTED_COUNTS" || return $?
203
211
  snapshot_nonbutler_autostart >"$AUTOSTART_NONBUTLER_AFTER" || return $?
204
212
  cmp -s -- "$AUTOSTART_NONBUTLER_BEFORE" "$AUTOSTART_NONBUTLER_AFTER" || return 1
@@ -336,19 +344,50 @@ Effective-spec audit helper:
336
344
  esac
337
345
  assert_only_running_butler ouro-butler || return $?
338
346
  }
347
+ assert_sanctuary_update_source_pin() {
348
+ (
349
+ PIN_SOURCE_CONTAINER=$1
350
+ PIN_SOURCE_IMAGE_ID=$2
351
+ validate_exact_image_id "$PIN_SOURCE_IMAGE_ID" || return $?
352
+ test "$(docker inspect --format '{{.Image}}' "$PIN_SOURCE_CONTAINER")" = "$PIN_SOURCE_IMAGE_ID" || return $?
353
+ case "$PIN_SOURCE_CONTAINER $PIN_SOURCE_IMAGE_ID" in
354
+ "ouro-butler sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d"|"ouro-butler sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d"|"ouro-butler-staging sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d") return 0 ;;
355
+ "ouro-butler "*) ;;
356
+ *) return 1 ;;
357
+ esac
358
+ PIN_SOURCE_IMAGE_REFERENCE=$(docker inspect --format '{{.Config.Image}}' "$PIN_SOURCE_CONTAINER") || return $?
359
+ printf '%s\n' "$PIN_SOURCE_IMAGE_REFERENCE" | grep -Eq '^ghcr\.io/ourostack/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || return $?
360
+ test "$(docker image inspect --format '{{.Id}}' "$PIN_SOURCE_IMAGE_REFERENCE")" = "$PIN_SOURCE_IMAGE_ID" || return $?
361
+ PIN_SOURCE_IMAGE_ORIGIN=$(docker image inspect --format '{{with .Config.Labels}}{{index . "org.opencontainers.image.source"}}{{end}}' "$PIN_SOURCE_IMAGE_ID") || return $?
362
+ test "$PIN_SOURCE_IMAGE_ORIGIN" = https://github.com/ourostack/ouroboros || return $?
363
+ test "$(docker inspect --format '{{with .Config.Labels}}{{index . "net.unraid.docker.managed"}}{{end}}' "$PIN_SOURCE_CONTAINER")" = dockerman || return $?
364
+ test "$(docker inspect --format '{{with .Config.Labels}}{{index . "net.unraid.docker.icon"}}{{end}}' "$PIN_SOURCE_CONTAINER")" = https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
365
+ )
366
+ }
339
367
  assert_legacy_alpha742_source() {
340
368
  EXPECTED_SOURCE_IMAGE_ID=$1
341
369
  AUDIT_RUNNER_IMAGE_ID=$2
342
- test "$EXPECTED_SOURCE_IMAGE_ID" = sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
370
+ assert_sanctuary_update_source_pin ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" || return $?
343
371
  audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" legacy-alpha742
344
372
  }
373
+ assert_prepackage_alpha797_source() {
374
+ EXPECTED_SOURCE_IMAGE_ID=$1
375
+ AUDIT_RUNNER_IMAGE_ID=$2
376
+ SOURCE_CONTAINER=$3
377
+ assert_sanctuary_update_source_pin "$SOURCE_CONTAINER" "$EXPECTED_SOURCE_IMAGE_ID" || return $?
378
+ audit_effective "$SOURCE_CONTAINER" "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" prepackage-alpha797
379
+ }
345
380
  assert_update_source() {
346
381
  EXPECTED_SOURCE_IMAGE_ID=$1
347
382
  AUDIT_RUNNER_IMAGE_ID=$2
348
383
  if test "$EXPECTED_SOURCE_IMAGE_ID" = sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d; then
349
384
  assert_legacy_alpha742_source "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
385
+ elif test "$EXPECTED_SOURCE_IMAGE_ID" = sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d; then
386
+ assert_prepackage_alpha797_source "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" ouro-butler
350
387
  else
351
- audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
388
+ assert_sanctuary_update_source_pin ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" || return $?
389
+ EXPECTED_SOURCE_IMAGE_REFERENCE=$(docker inspect --format '{{.Config.Image}}' ouro-butler) || return $?
390
+ audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" canonical "$EXPECTED_SOURCE_IMAGE_REFERENCE" https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
352
391
  fi
353
392
  }
354
393
  validate_sanctuary_roots() {
@@ -576,6 +615,7 @@ Effective-spec audit helper:
576
615
  test -n "${BACKUP_ROOT-}" || return 1
577
616
  test -n "${IMAGE_ID-}" || return 1
578
617
  test -n "${AUDIT_RUNNER_IMAGE_ID-}" || return 1
618
+ test -n "${RESTORE_VERSION_IMAGE-}" || return 1
579
619
  case "$BACKUP_ROOT" in
580
620
  /*) ;;
581
621
  *) return 1 ;;
@@ -589,7 +629,12 @@ Effective-spec audit helper:
589
629
  validate_sanctuary_roots "$BACKUP_ROOT/runtime/.ouro-cli" "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
590
630
  validate_exact_image_id "$IMAGE_ID" || return $?
591
631
  validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID" || return $?
632
+ test "$IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
633
+ test "$IMAGE_ID" != sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d || return $?
592
634
  test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
635
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d || return $?
636
+ printf '%s\n' "$RESTORE_VERSION_IMAGE" | grep -Eq '^ghcr\.io/ourostack/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || return $?
637
+ test "$(docker image inspect --format '{{.Id}}' "$RESTORE_VERSION_IMAGE")" = "$IMAGE_ID" || return $?
593
638
  RESTORE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
594
639
  RESTORE_NAME_COUNTS=$(printf '%s\n' "$RESTORE_CONTAINER_NAMES" | awk '
595
640
  $0 == "ouro-butler" { production++ }
@@ -646,7 +691,7 @@ Effective-spec audit helper:
646
691
  MIGRATE_OPERATION=$2
647
692
  MIGRATE_ROLLBACK_IMAGE_ID=${3-}
648
693
  validate_exact_image_id "$MIGRATE_IMAGE_ID" || return $?
649
- case "$MIGRATE_OPERATION" in migrate|rollback|commit|status) ;; *) return 1 ;; esac
694
+ case "$MIGRATE_OPERATION" in migrate|rollback|finalize-rollback|commit|status|inspect) ;; *) return 1 ;; esac
650
695
  if test "$MIGRATE_OPERATION" = migrate; then
651
696
  validate_exact_image_id "$MIGRATE_ROLLBACK_IMAGE_ID" || return $?
652
697
  test "$MIGRATE_IMAGE_ID" != "$MIGRATE_ROLLBACK_IMAGE_ID" || return 1
@@ -662,22 +707,226 @@ Effective-spec audit helper:
662
707
  --agent-root /home/ouro/AgentBundles/sanctuary.ouro \
663
708
  --operation "$MIGRATE_OPERATION" "$@" || return $?
664
709
  }
665
- recover_pending_sanctuary_bundle_migration() {
666
- RECOVERY_IMAGE_ID=$1
667
- RECOVERY_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
668
- RECOVERY_RECORD=$RECOVERY_AGENT_ROOT/.sanctuary-package-managed-rollback.json
669
- RECOVERY_COMMITTING_RECORD=$RECOVERY_RECORD.committing
670
- RECOVERY_RECORD_COUNT=0
671
- for RECOVERY_CANDIDATE in "$RECOVERY_RECORD" "$RECOVERY_COMMITTING_RECORD"; do
672
- test ! -L "$RECOVERY_CANDIDATE" || return 1
673
- if test -e "$RECOVERY_CANDIDATE"; then
674
- test -f "$RECOVERY_CANDIDATE" || return 1
675
- RECOVERY_RECORD_COUNT=$((RECOVERY_RECORD_COUNT + 1))
710
+ read_sanctuary_bundle_transaction_status() {
711
+ READ_BUNDLE_IMAGE_ID=$1
712
+ READ_BUNDLE_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
713
+ READ_BUNDLE_EXPECTED_UID=10001
714
+ READ_BUNDLE_EXPECTED_GID=10001
715
+ READ_BUNDLE_ROLLBACK_RECORD=$READ_BUNDLE_AGENT_ROOT/.sanctuary-package-managed-rollback.json
716
+ READ_BUNDLE_COMMITTING_RECORD=$READ_BUNDLE_ROLLBACK_RECORD.committing
717
+ validate_exact_image_id "$READ_BUNDLE_IMAGE_ID" || return $?
718
+ READ_BUNDLE_ROOT_STATE=$(/usr/local/bin/node -e '
719
+ const fs = require("node:fs");
720
+ const path = require("node:path");
721
+ const [rootPath, expectedUid, expectedGid] = process.argv.slice(1);
722
+ if (!path.isAbsolute(rootPath) || path.normalize(rootPath) !== rootPath) process.exit(1);
723
+ let current = path.parse(rootPath).root;
724
+ let rootStat;
725
+ for (const segment of rootPath.slice(current.length).split(path.sep)) {
726
+ current = path.join(current, segment);
727
+ let stat;
728
+ try { stat = fs.lstatSync(current); } catch (error) {
729
+ if (error?.code === "ENOENT") { process.stdout.write("missing"); process.exit(0); }
730
+ process.exit(1);
731
+ }
732
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(current) !== current) process.exit(1);
733
+ rootStat = stat;
734
+ }
735
+ if (!rootStat || rootStat.uid !== Number(expectedUid) || rootStat.gid !== Number(expectedGid) || (rootStat.mode & 0o777) !== 0o700) process.exit(1);
736
+ process.stdout.write("present");
737
+ ' "$READ_BUNDLE_AGENT_ROOT" "$READ_BUNDLE_EXPECTED_UID" "$READ_BUNDLE_EXPECTED_GID") || return $?
738
+ if test "$READ_BUNDLE_ROOT_STATE" = missing; then
739
+ printf 'null\n'
740
+ return 0
741
+ fi
742
+ test "$READ_BUNDLE_ROOT_STATE" = present || return 1
743
+ READ_BUNDLE_STAGING_RESIDUE=$(find "$READ_BUNDLE_AGENT_ROOT" -mindepth 1 -maxdepth 1 \( \
744
+ -name '.sanctuary-package-managed-rollback.json.package-migration.*' -o \
745
+ -name '.sanctuary-package-managed-rollback.json.committing.package-migration.*' \
746
+ \) -print -quit) || return $?
747
+ test -z "$READ_BUNDLE_STAGING_RESIDUE" || return 1
748
+ READ_BUNDLE_RECORD_COUNT=0
749
+ for READ_BUNDLE_CANDIDATE in "$READ_BUNDLE_ROLLBACK_RECORD" "$READ_BUNDLE_COMMITTING_RECORD"; do
750
+ test ! -L "$READ_BUNDLE_CANDIDATE" || return 1
751
+ if test -e "$READ_BUNDLE_CANDIDATE"; then
752
+ test -f "$READ_BUNDLE_CANDIDATE" || return 1
753
+ READ_BUNDLE_RECORD_COUNT=$((READ_BUNDLE_RECORD_COUNT + 1))
754
+ fi
755
+ done
756
+ test "$READ_BUNDLE_RECORD_COUNT" -le 1 || return 1
757
+ if test "$READ_BUNDLE_RECORD_COUNT" -eq 0; then
758
+ printf 'null\n'
759
+ return 0
760
+ fi
761
+ migrate_sanctuary_package_managed_bundle "$READ_BUNDLE_IMAGE_ID" status || return $?
762
+ }
763
+ verify_dockerman_and_community_apps() {
764
+ VERIFY_VERSION_IMAGE=$1
765
+ VERIFY_INSTALL_PROOF_PATH=$2
766
+ VERIFY_TEMPLATE_PATH=/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
767
+ test "$(stat -c '%u:%g:%a' "$VERIFY_TEMPLATE_PATH")" = 0:0:600 || return $?
768
+ test ! -e "$VERIFY_INSTALL_PROOF_PATH" || return 1
769
+ timeout -s KILL 20 /usr/bin/php -r '
770
+ [$expectedImage, $outputPath] = array_slice($argv, 1);
771
+ $expectedPath = "/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml";
772
+ $expectedUrl = "https://raw.githubusercontent.com/ourostack/ouroboros/main/deploy/unraid/sanctuary.xml";
773
+ $expectedIcon = "https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png";
774
+ $communityAppsEntryPath = "/usr/local/emhttp/plugins/community.applications/include/exec.php";
775
+ $communityAppsHelperPath = "/usr/local/emhttp/plugins/community.applications/include/previous_apps_helpers.php";
776
+ $communityAppsDependencies = ["/usr/local/emhttp/plugins/community.applications/include/paths.php", "/usr/local/emhttp/plugins/community.applications/include/plugin_identity.php", "/usr/local/emhttp/plugins/community.applications/include/helpers.php"];
777
+ if (!preg_match("#^ghcr\\.io/ourostack/ouroboros-butler:[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$#", $expectedImage)) exit(2);
778
+ $readActivePhp = static function($path) {
779
+ if (!is_file($path) || is_link($path) || fileowner($path) !== 0 || (fileperms($path) & 0022) !== 0 || realpath($path) !== $path) exit(3);
780
+ $lint = proc_open(["/usr/bin/php", "-l", $path], [0 => ["file", "/dev/null", "r"], 1 => ["file", "/dev/null", "w"], 2 => ["file", "/dev/null", "w"]], $pipes);
781
+ if (!is_resource($lint) || proc_close($lint) !== 0) exit(4);
782
+ $source = php_strip_whitespace($path);
783
+ if (!is_string($source) || $source === "") exit(4);
784
+ return $source;
785
+ };
786
+ $functionSlice = static function($source, $start, $next) {
787
+ if (substr_count($source, $start) !== 1 || substr_count($source, $next) !== 1) return false;
788
+ $startAt = strpos($source, $start);
789
+ $nextAt = strpos($source, $next, $startAt + strlen($start));
790
+ return $nextAt !== false && $nextAt > $startAt ? substr($source, $startAt, $nextAt - $startAt) : false;
791
+ };
792
+ $entrySource = $readActivePhp($communityAppsEntryPath);
793
+ $inlineBody = $functionSlice($entrySource, "function previous_apps(", "function remove_application(");
794
+ $delegatedBody = $functionSlice($entrySource, "function previous_apps(", "function installedAndPreviousCounts(");
795
+ $isInline = $inlineBody !== false && hash("sha256", $inlineBody) === "1914b9c6b8910b7bb116dad48fe76d4790a069becbf0d68b3822ee3df7f22b1c";
796
+ $isDelegated = $delegatedBody !== false && hash("sha256", $delegatedBody) === "16b52cd87cc3f74915bf172f8a6eb46f981529208766916d4aeedf4b3344a3a4";
797
+ if (($isInline ? 1 : 0) + ($isDelegated ? 1 : 0) !== 1) exit(4);
798
+ $_SERVER["DOCUMENT_ROOT"] = "/usr/local/emhttp";
799
+ $docroot = $_SERVER["DOCUMENT_ROOT"];
800
+ require_once "$docroot/plugins/dynamix/include/Wrappers.php";
801
+ require_once "$docroot/plugins/dynamix.docker.manager/include/DockerClient.php";
802
+ $driver = DockerUtil::driver();
803
+ $client = new DockerClient();
804
+ $templates = new DockerTemplates();
805
+ $resolved = $templates->getUserTemplate("ouro-butler");
806
+ if ($resolved !== $expectedPath) exit(5);
807
+ $xml = @simplexml_load_file($resolved);
808
+ if (!$xml || (string)$xml->Name !== "ouro-butler" || (string)$xml->Repository !== $expectedImage || (string)$xml->TemplateURL !== $expectedUrl || (string)$xml->Icon !== $expectedIcon || trim((string)$xml->WebUI) !== "") exit(6);
809
+ $containers = $client->getDockerContainers();
810
+ $matches = array_values(array_filter($containers, fn($container) => $container["Name"] === "ouro-butler"));
811
+ if (count($matches) !== 1) exit(7);
812
+ $container = $matches[0];
813
+ if ($container["Image"] !== $expectedImage || $container["Manager"] !== "dockerman" || $container["Icon"] !== $expectedIcon || !empty($container["Url"])) exit(8);
814
+ $communityAppsInstalled = $container["Name"] === (string)$xml->Name && (str_starts_with(str_replace("library/", "", $container["Image"]), (string)$xml->Repository) || str_starts_with($container["Image"], (string)$xml->Repository));
815
+ if (!$communityAppsInstalled) exit(9);
816
+ if ($isDelegated) {
817
+ $readActivePhp($communityAppsHelperPath);
818
+ foreach ($communityAppsDependencies as $dependencyPath) $readActivePhp($dependencyPath);
819
+ require_once "$docroot/plugins/community.applications/include/helpers.php";
820
+ require_once $communityAppsHelperPath;
821
+ if (!is_callable(["PreviousAppsHelpers", "collectDockerApplications"])) exit(4);
822
+ $helperUpdateCount = 0;
823
+ $helperRows = PreviousAppsHelpers::collectDockerApplications(true, "true", "docker", $containers, $helperUpdateCount, [], [], [], []);
824
+ if (!is_array($helperRows)) exit(9);
825
+ $helperMatches = array_values(array_filter($helperRows, fn($row) => is_array($row) && ($row["InstallPath"] ?? null) === $expectedPath && ($row["Name"] ?? null) === "ouro-butler" && ($row["Repository"] ?? null) === $expectedImage && ($row["TemplateURL"] ?? null) === $expectedUrl && ($row["Icon"] ?? null) === $expectedIcon));
826
+ if (count($helperMatches) !== 1) exit(9);
827
+ $communityAppsStateModel = "previous-apps-helper-v1";
828
+ $communityAppsImplementationPath = $communityAppsHelperPath;
829
+ $communityAppsImplementationSymbol = "PreviousAppsHelpers::collectDockerApplications";
830
+ } else {
831
+ $communityAppsStateModel = "previous-apps-inline-v1";
832
+ $communityAppsImplementationPath = $communityAppsEntryPath;
833
+ $communityAppsImplementationSymbol = "previous_apps";
834
+ }
835
+ $proof = [
836
+ "dockerMan" => ["templatePath" => $resolved, "name" => (string)$xml->Name, "repository" => (string)$xml->Repository, "templateUrl" => (string)$xml->TemplateURL, "icon" => (string)$xml->Icon],
837
+ "communityApps" => ["installed" => true, "name" => (string)$xml->Name, "repository" => (string)$xml->Repository, "templateUrl" => (string)$xml->TemplateURL, "stateModel" => $communityAppsStateModel, "entryPath" => $communityAppsEntryPath, "entryFunction" => "previous_apps", "implementationPath" => $communityAppsImplementationPath, "implementationSymbol" => $communityAppsImplementationSymbol],
838
+ ];
839
+ $temporary = $outputPath . ".tmp";
840
+ if (file_exists($temporary) || is_link($temporary) || file_exists($outputPath) || is_link($outputPath)) exit(10);
841
+ $payload = json_encode($proof, JSON_UNESCAPED_SLASHES) . "\n";
842
+ $handle = @fopen($temporary, "x");
843
+ if ($handle === false || fwrite($handle, $payload) !== strlen($payload) || !fflush($handle) || !fsync($handle) || !fclose($handle) || !chmod($temporary, 0600) || !chown($temporary, 0) || !chgrp($temporary, 0) || !rename($temporary, $outputPath)) exit(11);
844
+ ' "$VERIFY_VERSION_IMAGE" "$VERIFY_INSTALL_PROOF_PATH" || return $?
845
+ test "$(stat -c '%u:%g:%a' "$VERIFY_INSTALL_PROOF_PATH")" = 0:0:600 || return $?
846
+ }
847
+ audit_registered_dockerman_template() {
848
+ REGISTERED_TEMPLATE_RUNNER_IMAGE_ID=$1
849
+ REGISTERED_TEMPLATE_VERSION_IMAGE=$2
850
+ REGISTERED_TEMPLATE_PATH=/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
851
+ validate_exact_image_id "$REGISTERED_TEMPLATE_RUNNER_IMAGE_ID" || return $?
852
+ printf '%s\n' "$REGISTERED_TEMPLATE_VERSION_IMAGE" | grep -Eq '^ghcr\.io/ourostack/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || return $?
853
+ test -f "$REGISTERED_TEMPLATE_PATH" && test ! -L "$REGISTERED_TEMPLATE_PATH" || return 1
854
+ docker run --rm --pull=never --network=none --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh --mount "type=bind,src=$REGISTERED_TEMPLATE_PATH,dst=/audit/sanctuary.xml,readonly" "$REGISTERED_TEMPLATE_RUNNER_IMAGE_ID" --persistent-template /audit/sanctuary.xml --runtime-policy /opt/ouro/deploy/unraid/container-runtime.json --expected-image-reference "$REGISTERED_TEMPLATE_VERSION_IMAGE" || return $?
855
+ }
856
+ verify_known_good_rollback_artifact() {
857
+ EXPECTED_KNOWN_GOOD_IMAGE_ID=$1
858
+ validate_exact_image_id "$EXPECTED_KNOWN_GOOD_IMAGE_ID" || return $?
859
+ MATCHING_KNOWN_GOOD_COUNT=0
860
+ for KNOWN_GOOD_NAME in ouro-butler-rollback ouro-butler-legacy-evidence; do
861
+ if docker container inspect "$KNOWN_GOOD_NAME" >/dev/null 2>&1; then
862
+ KNOWN_GOOD_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$KNOWN_GOOD_NAME") || return $?
863
+ if test "$KNOWN_GOOD_IMAGE_ID" = "$EXPECTED_KNOWN_GOOD_IMAGE_ID"; then
864
+ test "$(docker inspect --format '{{.State.Running}}' "$KNOWN_GOOD_NAME")" = false || return 1
865
+ MATCHING_KNOWN_GOOD_COUNT=$((MATCHING_KNOWN_GOOD_COUNT + 1))
866
+ fi
676
867
  fi
677
868
  done
678
- test "$RECOVERY_RECORD_COUNT" -le 1 || return 1
679
- test "$RECOVERY_RECORD_COUNT" -eq 1 || return 0
680
- RECOVERY_STATUS=$(migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" status) || return $?
869
+ test "$MATCHING_KNOWN_GOOD_COUNT" -eq 1 || return 1
870
+ }
871
+ write_dockerman_final_proof() {
872
+ FINAL_PROOF_IMAGE_ID=$1
873
+ FINAL_PROOF_VERSION_IMAGE=$2
874
+ FINAL_PROOF_ICON=$3
875
+ FINAL_PROOF_ROOT=$4
876
+ validate_exact_image_id "$FINAL_PROOF_IMAGE_ID" || return $?
877
+ test "$FINAL_PROOF_VERSION_IMAGE" = "ghcr.io/ourostack/ouroboros-butler:$PACKAGE_VERSION" || return 1
878
+ install -d -m 0700 -o 0 -g 0 "$FINAL_PROOF_ROOT" || return $?
879
+ FINAL_CONTAINER_PATH=$FINAL_PROOF_ROOT/container.json
880
+ FINAL_BUNDLE_PATH=$FINAL_PROOF_ROOT/bundle.json
881
+ FINAL_INSTALL_PATH=$FINAL_PROOF_ROOT/install.json
882
+ FINAL_JELLYFIN_PATH=$FINAL_PROOF_ROOT/jellyfin.json
883
+ FINAL_PROOF_PATH=$FINAL_PROOF_ROOT/final-proof.json
884
+ test ! -e "$FINAL_CONTAINER_PATH" && test ! -e "$FINAL_BUNDLE_PATH" && test ! -e "$FINAL_INSTALL_PATH" && test ! -e "$FINAL_JELLYFIN_PATH" && test ! -e "$FINAL_PROOF_PATH" || return 1
885
+ docker inspect ouro-butler >"$FINAL_CONTAINER_PATH" || return $?
886
+ migrate_sanctuary_package_managed_bundle "$FINAL_PROOF_IMAGE_ID" inspect >"$FINAL_BUNDLE_PATH" || return $?
887
+ verify_dockerman_and_community_apps "$FINAL_PROOF_VERSION_IMAGE" "$FINAL_INSTALL_PATH" || return $?
888
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" jellyfin-status >"$FINAL_JELLYFIN_PATH" || return $?
889
+ chown 0:0 "$FINAL_CONTAINER_PATH" "$FINAL_BUNDLE_PATH" "$FINAL_JELLYFIN_PATH" || return $?
890
+ chmod 0600 "$FINAL_CONTAINER_PATH" "$FINAL_BUNDLE_PATH" "$FINAL_JELLYFIN_PATH" || return $?
891
+ FINAL_AUTOSTART_COUNT=$(awk '$1 == "ouro-butler" { count++ } END { print count + 0 }' /var/lib/docker/unraid-autostart) || return $?
892
+ /usr/local/bin/node -e '
893
+ const fs = require("node:fs");
894
+ const [containerPath, bundlePath, installPath, jellyfinPath, outputPath, imageId, imageReference, icon, autostartCount] = process.argv.slice(1);
895
+ const containers = JSON.parse(fs.readFileSync(containerPath, "utf8"));
896
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
897
+ const install = JSON.parse(fs.readFileSync(installPath, "utf8"));
898
+ const jellyfin = JSON.parse(fs.readFileSync(jellyfinPath, "utf8"));
899
+ if (!Array.isArray(containers) || containers.length !== 1) process.exit(1);
900
+ const source = containers[0];
901
+ const labels = source.Config?.Labels;
902
+ const health = source.State?.Health?.Status;
903
+ if (source.Name !== "/ouro-butler" || source.Image !== imageId || source.Config?.Image !== imageReference || source.State?.Running !== true || health !== "healthy" || autostartCount !== "1" || labels?.["net.unraid.docker.managed"] !== "dockerman" || labels?.["net.unraid.docker.icon"] !== icon || Object.prototype.hasOwnProperty.call(labels ?? {}, "net.unraid.docker.webui")) process.exit(1);
904
+ if (bundle?.ok !== true || bundle?.data?.parity !== "exact" || bundle?.data?.journalState !== "absent" || bundle?.data?.ready !== true) process.exit(1);
905
+ const proof = { container: { name: source.Name, imageId: source.Image, imageReference: source.Config.Image, running: source.State.Running, healthy: health === "healthy", autostart: true, labels: { "net.unraid.docker.managed": labels["net.unraid.docker.managed"], "net.unraid.docker.icon": labels["net.unraid.docker.icon"] } }, bundle, ...install, jellyfin };
906
+ fs.writeFileSync(outputPath, `${JSON.stringify(proof)}\n`, { mode: 0o600, flag: "wx" });
907
+ ' "$FINAL_CONTAINER_PATH" "$FINAL_BUNDLE_PATH" "$FINAL_INSTALL_PATH" "$FINAL_JELLYFIN_PATH" "$FINAL_PROOF_PATH" "$FINAL_PROOF_IMAGE_ID" "$FINAL_PROOF_VERSION_IMAGE" "$FINAL_PROOF_ICON" "$FINAL_AUTOSTART_COUNT" || return $?
908
+ chown 0:0 "$FINAL_PROOF_PATH" || return $?
909
+ chmod 0600 "$FINAL_PROOF_PATH" || return $?
910
+ printf '%s\n' "$FINAL_PROOF_PATH"
911
+ }
912
+ start_only_butler_for_recovery() {
913
+ RECOVERY_START_STATE=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
914
+ case "$RECOVERY_START_STATE" in
915
+ true)
916
+ assert_only_running_butler ouro-butler || return $?
917
+ ;;
918
+ false)
919
+ assert_only_running_butler - || return $?
920
+ docker start ouro-butler || return $?
921
+ assert_only_running_butler ouro-butler || return $?
922
+ ;;
923
+ *) return 1 ;;
924
+ esac
925
+ }
926
+ recover_pending_sanctuary_bundle_migration() {
927
+ RECOVERY_IMAGE_ID=$1
928
+ RECOVERY_STATUS=$(read_sanctuary_bundle_transaction_status "$RECOVERY_IMAGE_ID") || return $?
929
+ test "$RECOVERY_STATUS" != null || return 0
681
930
  RECOVERY_IDENTITIES=$(printf '%s' "$RECOVERY_STATUS" | /usr/local/bin/node -e '
682
931
  let input = "";
683
932
  process.stdin.setEncoding("utf8");
@@ -702,15 +951,10 @@ Effective-spec audit helper:
702
951
  ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
703
952
  RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
704
953
  test "$(docker inspect --format '{{.State.Running}}' ouro-butler)" = true || return 1
705
- if test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID"; then
706
- RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
707
- test "$RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
708
- test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
709
- elif test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID"; then
710
- ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
711
- else
712
- return 1
713
- fi
954
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID" || return 1
955
+ RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
956
+ test "$RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
957
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
714
958
  assert_only_running_butler ouro-butler || return $?
715
959
  assert_update_source "$RECOVERY_PRODUCTION_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
716
960
  wait_butler_ready ouro-butler || return $?
@@ -741,17 +985,17 @@ Effective-spec audit helper:
741
985
  RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
742
986
  test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
743
987
  ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
988
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" rollback || return $?
744
989
  else
745
990
  return 1
746
991
  fi
747
992
  RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
748
993
  test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
749
994
  assert_update_source "$RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
750
- docker start ouro-butler || return $?
751
- assert_only_running_butler ouro-butler || return $?
995
+ start_only_butler_for_recovery || return $?
752
996
  wait_butler_ready ouro-butler || return $?
753
997
  enable_butler_autostart || return $?
754
- migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" commit || return $?
998
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" finalize-rollback || return $?
755
999
  }
756
1000
  rollback_sanctuary_bundle_if_pending() {
757
1001
  OPTIONAL_ROLLBACK_IMAGE_ID=$1
@@ -765,6 +1009,219 @@ Effective-spec audit helper:
765
1009
  migrate_sanctuary_package_managed_bundle "$OPTIONAL_ROLLBACK_IMAGE_ID" rollback || return $?
766
1010
  fi
767
1011
  }
1012
+ finalize_sanctuary_bundle_rollback_if_retained() {
1013
+ FINALIZE_ROLLBACK_IMAGE_ID=$1
1014
+ FINALIZE_ROLLBACK_STATUS=$(read_sanctuary_bundle_transaction_status "$FINALIZE_ROLLBACK_IMAGE_ID") || return $?
1015
+ test "$FINALIZE_ROLLBACK_STATUS" != null || return 0
1016
+ FINALIZE_ROLLBACK_STATE=$(printf '%s' "$FINALIZE_ROLLBACK_STATUS" | /usr/local/bin/node -e '
1017
+ let input = "";
1018
+ process.stdin.setEncoding("utf8");
1019
+ process.stdin.on("data", chunk => { input += chunk; });
1020
+ process.stdin.on("end", () => {
1021
+ const value = JSON.parse(input);
1022
+ if (!value || !["rollback", "committing"].includes(value.state)) process.exit(1);
1023
+ process.stdout.write(value.state);
1024
+ });
1025
+ ') || return $?
1026
+ test "$FINALIZE_ROLLBACK_STATE" = rollback || return 1
1027
+ migrate_sanctuary_package_managed_bundle "$FINALIZE_ROLLBACK_IMAGE_ID" finalize-rollback || return $?
1028
+ }
1029
+ write_dockerman_recovery_evidence() {
1030
+ RECOVERY_EVIDENCE_BUNDLE_STATE=$1
1031
+ RECOVERY_EVIDENCE_PRODUCTION=$2
1032
+ RECOVERY_EVIDENCE_INSPECTION_PATH=$3
1033
+ RECOVERY_EVIDENCE_PATH=$4
1034
+ test ! -e "$RECOVERY_EVIDENCE_PATH" || return 1
1035
+ /usr/local/bin/node -e '
1036
+ const fs = require("node:fs");
1037
+ const [bundleJournalState, production, inspectionPath, outputPath] = process.argv.slice(1);
1038
+ const evidence = { bundleJournalState, production };
1039
+ if (inspectionPath !== "-") evidence.inspection = JSON.parse(fs.readFileSync(inspectionPath, "utf8"));
1040
+ fs.writeFileSync(outputPath, `${JSON.stringify(evidence)}\n`, { mode: 0o600, flag: "wx" });
1041
+ ' "$RECOVERY_EVIDENCE_BUNDLE_STATE" "$RECOVERY_EVIDENCE_PRODUCTION" "$RECOVERY_EVIDENCE_INSPECTION_PATH" "$RECOVERY_EVIDENCE_PATH" || return $?
1042
+ chown 0:0 "$RECOVERY_EVIDENCE_PATH" || return $?
1043
+ chmod 0600 "$RECOVERY_EVIDENCE_PATH" || return $?
1044
+ }
1045
+ recover_dockerman_template_transaction() {
1046
+ TEMPLATE_RECOVERY_IDENTITY=$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-identity) || return $?
1047
+ if test "$TEMPLATE_RECOVERY_IDENTITY" = null; then
1048
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recover-status)" = null || return 1
1049
+ return 0
1050
+ fi
1051
+ TEMPLATE_RECOVERY_IDENTITIES=$(printf '%s' "$TEMPLATE_RECOVERY_IDENTITY" | /usr/local/bin/node -e '
1052
+ let input = "";
1053
+ process.stdin.setEncoding("utf8");
1054
+ process.stdin.on("data", chunk => { input += chunk; });
1055
+ process.stdin.on("end", () => {
1056
+ const value = JSON.parse(input);
1057
+ if (!value || !["rollback", "committing"].includes(value.state)) process.exit(1);
1058
+ process.stdout.write([value.state, value.rollbackImageId, value.targetImageId, value.canonicalVersionTag, value.reviewedManifestDigest].join(" "));
1059
+ });
1060
+ ') || return $?
1061
+ set -- $TEMPLATE_RECOVERY_IDENTITIES
1062
+ test "$#" -eq 5 || return 1
1063
+ TEMPLATE_RECOVERY_STATE=$1
1064
+ TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID=$2
1065
+ TEMPLATE_RECOVERY_TARGET_IMAGE_ID=$3
1066
+ TEMPLATE_RECOVERY_VERSION_IMAGE=$4
1067
+ TEMPLATE_RECOVERY_MANIFEST_DIGEST=$5
1068
+ test "$TEMPLATE_RECOVERY_TARGET_IMAGE_ID" = "$IMAGE_ID" || return 1
1069
+ test "$TEMPLATE_RECOVERY_VERSION_IMAGE" = "$VERSION_IMAGE" || return 1
1070
+ test "$TEMPLATE_RECOVERY_MANIFEST_DIGEST" = "$MANIFEST_DIGEST" || return 1
1071
+ validate_exact_image_id "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return $?
1072
+ TEMPLATE_RECOVERY_BUNDLE_STATUS=$(read_sanctuary_bundle_transaction_status "$IMAGE_ID") || return $?
1073
+ TEMPLATE_RECOVERY_BUNDLE_IDENTITIES=$(printf '%s' "$TEMPLATE_RECOVERY_BUNDLE_STATUS" | /usr/local/bin/node -e '
1074
+ let input = "";
1075
+ process.stdin.setEncoding("utf8");
1076
+ process.stdin.on("data", chunk => { input += chunk; });
1077
+ process.stdin.on("end", () => {
1078
+ const value = JSON.parse(input);
1079
+ if (value === null) process.stdout.write("absent - -");
1080
+ else if (value && ["rollback", "committing"].includes(value.state) && typeof value.rollbackImageId === "string" && typeof value.targetImageId === "string") process.stdout.write([value.state, value.rollbackImageId, value.targetImageId].join(" "));
1081
+ else process.exit(1);
1082
+ });
1083
+ ') || return $?
1084
+ set -- $TEMPLATE_RECOVERY_BUNDLE_IDENTITIES
1085
+ test "$#" -eq 3 || return 1
1086
+ TEMPLATE_RECOVERY_BUNDLE_STATE=$1
1087
+ TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID=$2
1088
+ TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID=$3
1089
+ if test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent; then
1090
+ test "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" = - && test "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" = - || return 1
1091
+ else
1092
+ validate_exact_image_id "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" || return $?
1093
+ validate_exact_image_id "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" || return $?
1094
+ test "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" != "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" || return 1
1095
+ test "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1096
+ test "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" = "$TEMPLATE_RECOVERY_TARGET_IMAGE_ID" || return 1
1097
+ fi
1098
+ TEMPLATE_RECOVERY_STATUS=$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recover-status) || return $?
1099
+ test "$TEMPLATE_RECOVERY_STATUS" = "$TEMPLATE_RECOVERY_IDENTITY" || return 1
1100
+ TEMPLATE_RECOVERY_EVIDENCE=$EVENT_ASSET_STAGE/template-recovery-evidence.json
1101
+ TEMPLATE_RECOVERY_INSPECTION=$EVENT_ASSET_STAGE/template-recovery-inspection.json
1102
+ TEMPLATE_RECOVERY_FINAL_ROOT=$EVENT_ASSET_STAGE/template-recovery-final
1103
+ if test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = rollback; then
1104
+ recover_pending_sanctuary_bundle_migration "$IMAGE_ID" || return $?
1105
+ TEMPLATE_RECOVERY_POST_BUNDLE_STATUS=$(read_sanctuary_bundle_transaction_status "$IMAGE_ID") || return $?
1106
+ test "$TEMPLATE_RECOVERY_POST_BUNDLE_STATUS" = null || return 1
1107
+ test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1108
+ write_dockerman_recovery_evidence absent rollback-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1109
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1110
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1111
+ return 0
1112
+ fi
1113
+ TEMPLATE_RECOVERY_PRODUCTION_PRESENT=false
1114
+ TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID=
1115
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1116
+ TEMPLATE_RECOVERY_PRODUCTION_PRESENT=true
1117
+ TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1118
+ fi
1119
+ if test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = committing; then
1120
+ test "$TEMPLATE_RECOVERY_PRODUCTION_PRESENT" = true || return 1
1121
+ test "$TEMPLATE_RECOVERY_STATE" = committing || return 1
1122
+ test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$IMAGE_ID" || return 1
1123
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1124
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
1125
+ assert_only_running_butler ouro-butler || return $?
1126
+ audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" canonical "$VERSION_IMAGE" "$TEMPLATE_ICON" || return $?
1127
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" inspect >"$TEMPLATE_RECOVERY_INSPECTION" || return $?
1128
+ chown 0:0 "$TEMPLATE_RECOVERY_INSPECTION" && chmod 0600 "$TEMPLATE_RECOVERY_INSPECTION" || return $?
1129
+ write_dockerman_recovery_evidence committing target-exact-committing "$TEMPLATE_RECOVERY_INSPECTION" "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1130
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"finish-bundle-commit"}' || return 1
1131
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit || return $?
1132
+ wait_butler_ready ouro-butler || return $?
1133
+ test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" || return 1
1134
+ verify_butler_autostart "1 0 0 0" || return $?
1135
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_PRODUCTION_PRESENT" = false; then
1136
+ test "$TEMPLATE_RECOVERY_STATE" = rollback || return 1
1137
+ if docker container inspect ouro-butler-staging >/dev/null 2>&1; then
1138
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
1139
+ ! docker container inspect ouro-butler-legacy-evidence >/dev/null 2>&1 || return 1
1140
+ TEMPLATE_RECOVERY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
1141
+ test "$TEMPLATE_RECOVERY_STAGING_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1142
+ assert_prepackage_alpha797_source "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" ouro-butler-staging || return $?
1143
+ TEMPLATE_RECOVERY_STAGING_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-staging) || return $?
1144
+ case "$TEMPLATE_RECOVERY_STAGING_RUNNING" in
1145
+ true)
1146
+ assert_only_running_butler ouro-butler-staging || return $?
1147
+ ;;
1148
+ false)
1149
+ assert_only_running_butler - || return $?
1150
+ docker start ouro-butler-staging || return $?
1151
+ assert_only_running_butler ouro-butler-staging || return $?
1152
+ ;;
1153
+ *) return 1 ;;
1154
+ esac
1155
+ set_butler_autostart staging || return $?
1156
+ write_dockerman_recovery_evidence absent adoption-source-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1157
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1158
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1159
+ return 0
1160
+ fi
1161
+ assert_only_running_butler - || return $?
1162
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1163
+ TEMPLATE_RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1164
+ test "$TEMPLATE_RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1165
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
1166
+ docker rename ouro-butler-rollback ouro-butler || return $?
1167
+ assert_update_source "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
1168
+ start_only_butler_for_recovery || return $?
1169
+ wait_butler_ready ouro-butler || return $?
1170
+ enable_butler_autostart || return $?
1171
+ write_dockerman_recovery_evidence absent rollback-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1172
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1173
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1174
+ return 0
1175
+ fi
1176
+ TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
1177
+ test "$TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1178
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return 1
1179
+ write_dockerman_recovery_evidence absent adoption-evidence-exact-stopped - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1180
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"quarantine-adoption"}' || return 1
1181
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1182
+ printf '%s\n' 'Legacy adoption recovery requires a reviewed retry; exact legacy evidence remains quarantined and stopped.' >&2
1183
+ return 1
1184
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_STATE" = rollback && test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID"; then
1185
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1186
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
1187
+ assert_update_source "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
1188
+ start_only_butler_for_recovery || return $?
1189
+ wait_butler_ready ouro-butler || return $?
1190
+ enable_butler_autostart || return $?
1191
+ write_dockerman_recovery_evidence absent rollback-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1192
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1193
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1194
+ return 0
1195
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_STATE" = rollback && test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"; then
1196
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1197
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
1198
+ TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
1199
+ test "$TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1200
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return 1
1201
+ audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" canonical "$VERSION_IMAGE" "$TEMPLATE_ICON" || return $?
1202
+ start_only_butler_for_recovery || return $?
1203
+ wait_butler_ready ouro-butler || return $?
1204
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" inspect >"$TEMPLATE_RECOVERY_INSPECTION" || return $?
1205
+ chown 0:0 "$TEMPLATE_RECOVERY_INSPECTION" && chmod 0600 "$TEMPLATE_RECOVERY_INSPECTION" || return $?
1206
+ write_dockerman_recovery_evidence absent adoption-target-exact-ready "$TEMPLATE_RECOVERY_INSPECTION" "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1207
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"roll-forward-adoption"}' || return 1
1208
+ enable_butler_autostart || return $?
1209
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" mark-committing >/dev/null || return $?
1210
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_STATE" = committing && test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"; then
1211
+ audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" canonical "$VERSION_IMAGE" "$TEMPLATE_ICON" || return $?
1212
+ wait_butler_ready ouro-butler || return $?
1213
+ verify_butler_autostart "1 0 0 0" || return $?
1214
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" inspect >"$TEMPLATE_RECOVERY_INSPECTION" || return $?
1215
+ chown 0:0 "$TEMPLATE_RECOVERY_INSPECTION" && chmod 0600 "$TEMPLATE_RECOVERY_INSPECTION" || return $?
1216
+ write_dockerman_recovery_evidence absent target-exact-ready "$TEMPLATE_RECOVERY_INSPECTION" "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1217
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"finish-template-commit"}' || return 1
1218
+ else
1219
+ return 1
1220
+ fi
1221
+ TEMPLATE_RECOVERY_FINAL_PROOF=$(write_dockerman_final_proof "$IMAGE_ID" "$VERSION_IMAGE" "$TEMPLATE_ICON" "$TEMPLATE_RECOVERY_FINAL_ROOT") || return $?
1222
+ verify_known_good_rollback_artifact "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return $?
1223
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" commit --proof "$TEMPLATE_RECOVERY_FINAL_PROOF" >/dev/null || return $?
1224
+ }
768
1225
  # Package installation and migration never grant restart authority. The
769
1226
  # migration is the pre-activation assertion: it fails before live mutation
770
1227
  # if packaged steward.json carries any routine grant. It preserves an
@@ -833,12 +1290,33 @@ Effective-spec audit helper:
833
1290
  test "$LEGACY_STAGING_IMAGE_ID" = "$EXPECTED_LEGACY_IMAGE_ID" || return 1
834
1291
  fi
835
1292
  }
1293
+ classify_sanctuary_update_source() {
1294
+ UPDATE_ENTRY_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1295
+ if printf '%s\n' "$UPDATE_ENTRY_CONTAINER_NAMES" | grep -Fxq ouro-butler-staging; then
1296
+ validate_sanctuary_legacy_staging || return $?
1297
+ assert_sanctuary_update_source_pin ouro-butler-staging "$LEGACY_STAGING_IMAGE_ID"
1298
+ else
1299
+ UPDATE_ENTRY_SOURCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1300
+ assert_update_topology "$UPDATE_ENTRY_SOURCE_IMAGE_ID" || return $?
1301
+ assert_sanctuary_update_source_pin ouro-butler "$UPDATE_ENTRY_SOURCE_IMAGE_ID"
1302
+ fi
1303
+ }
1304
+ admit_sanctuary_update_entry() {
1305
+ UPDATE_ENTRY_RECOVERY_JOURNAL=/boot/config/custom/ouro-butler/docker-man-template-transaction.json
1306
+ if test -e "$UPDATE_ENTRY_RECOVERY_JOURNAL" || test -L "$UPDATE_ENTRY_RECOVERY_JOURNAL"; then
1307
+ test -f "$UPDATE_ENTRY_RECOVERY_JOURNAL" && test ! -L "$UPDATE_ENTRY_RECOVERY_JOURNAL" || return 1
1308
+ test "$(stat -c '%u:%g:%a' "$UPDATE_ENTRY_RECOVERY_JOURNAL")" = 0:0:600
1309
+ else
1310
+ classify_sanctuary_update_source
1311
+ fi
1312
+ }
836
1313
  prepare_sanctuary_legacy_adoption() {
837
1314
  IMAGE_ID=$1
838
1315
  validate_exact_image_id "$IMAGE_ID" || return $?
839
1316
  validate_sanctuary_legacy_staging || return $?
840
1317
  PREPARED_LEGACY_CONTAINER_ID=$LEGACY_STAGING_CONTAINER_ID
841
1318
  PREPARED_LEGACY_IMAGE_ID=$LEGACY_STAGING_IMAGE_ID
1319
+ assert_prepackage_alpha797_source "$PREPARED_LEGACY_IMAGE_ID" "$IMAGE_ID" ouro-butler-staging || return $?
842
1320
  prepare_canonical_sanctuary_roots "$IMAGE_ID" || return $?
843
1321
  bootstrap_sanctuary_vault "$IMAGE_ID" \
844
1322
  /mnt/user/appdata/ouro-butler/runtime/container-credentials.json \
@@ -976,7 +1454,14 @@ Effective-spec audit helper:
976
1454
  validate_sanctuary_legacy_staging "$ADOPTION_PREPARED_CONTAINER_ID" "$ADOPTION_PREPARED_IMAGE_ID" || return $?
977
1455
  LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
978
1456
  LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
979
- disable_butler_autostart || return $?
1457
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" prepare --source-template "$STAGED_TEMPLATE" --version-tag "$VERSION_IMAGE" --manifest-digest "$MANIFEST_DIGEST" --rollback-image-id "$LEGACY_STAGING_IMAGE_ID" --target-image-id "$IMAGE_ID" >/dev/null || return $?
1458
+ if disable_butler_autostart; then
1459
+ :
1460
+ else
1461
+ ADOPTION_AUTOSTART_STATUS=$?
1462
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1463
+ return "$ADOPTION_AUTOSTART_STATUS"
1464
+ fi
980
1465
  if docker stop "$LEGACY_STAGING_CONTAINER_ID" \
981
1466
  && CURRENT_LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$LEGACY_STAGING_CONTAINER_ID") \
982
1467
  && test "$CURRENT_LEGACY_STAGING_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
@@ -986,33 +1471,32 @@ Effective-spec audit helper:
986
1471
  && test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
987
1472
  && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false \
988
1473
  && assert_only_running_butler - \
989
- && docker create --name ouro-butler-staging --network host --restart unless-stopped --user 10001:10001 \
1474
+ && test "$(docker buildx imagetools inspect "$VERSION_IMAGE" --format '{{.Manifest.Digest}}')" = "$MANIFEST_DIGEST" \
1475
+ && test "$(docker image inspect --format '{{.Id}}' "$VERSION_IMAGE")" = "$IMAGE_ID" \
1476
+ && docker create --pull=never --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1477
+ --label net.unraid.docker.managed=dockerman \
1478
+ --label "net.unraid.docker.icon=$TEMPLATE_ICON" \
990
1479
  --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
991
1480
  --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
992
1481
  --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
993
- "$IMAGE_ID" \
994
- && audit_effective ouro-butler-staging "$IMAGE_ID" "$IMAGE_ID" \
995
- && docker start ouro-butler-staging \
996
- && assert_only_running_butler ouro-butler-staging \
997
- && wait_butler_ready ouro-butler-staging \
998
- && docker stop ouro-butler-staging \
999
- && test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$IMAGE_ID" \
1000
- && docker rm ouro-butler-staging \
1482
+ "$VERSION_IMAGE" \
1483
+ && audit_effective ouro-butler "$IMAGE_ID" "$IMAGE_ID" canonical "$VERSION_IMAGE" "$TEMPLATE_ICON" \
1001
1484
  && assert_only_running_butler - \
1002
- && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1003
- --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1004
- --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1005
- --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
1006
- "$IMAGE_ID" \
1007
- && audit_effective ouro-butler "$IMAGE_ID" "$IMAGE_ID" \
1008
1485
  && docker start ouro-butler \
1009
1486
  && assert_only_running_butler ouro-butler \
1487
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" \
1010
1488
  && wait_butler_ready ouro-butler \
1011
- && enable_butler_autostart; then
1489
+ && enable_butler_autostart \
1490
+ && /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" mark-committing >/dev/null; then
1491
+ ADOPTION_FINAL_ROOT="$EVENT_ASSET_STAGE/adoption-final-proof"
1492
+ ADOPTION_FINAL_PROOF=$(write_dockerman_final_proof "$IMAGE_ID" "$VERSION_IMAGE" "$TEMPLATE_ICON" "$ADOPTION_FINAL_ROOT") || return $?
1493
+ verify_known_good_rollback_artifact "$LEGACY_STAGING_IMAGE_ID" || return $?
1494
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" commit --proof "$ADOPTION_FINAL_PROOF" >/dev/null || return $?
1012
1495
  return 0
1013
1496
  else
1014
1497
  ADOPTION_STATUS=$?
1015
1498
  fi
1499
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" verify-jellyfin >/dev/null || return $?
1016
1500
  ADOPTION_RECOVERY_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1017
1501
  case "
1018
1502
  $ADOPTION_RECOVERY_NAMES
@@ -1055,6 +1539,7 @@ ouro-butler-staging
1055
1539
  CURRENT_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
1056
1540
  test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" || return $?
1057
1541
  test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return $?
1542
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1058
1543
  return "$ADOPTION_STATUS"
1059
1544
  }
1060
1545
  validate_sanctuary_legacy_import_marker() {
@@ -1589,22 +2074,36 @@ NODE
1589
2074
  return 1
1590
2075
  }
1591
2076
  Update:
1592
- Build and verify a new ouro-butler:<version> image first. The tag is only a
1593
- lookup handle and never authorizes container creation. Resolve and validate
1594
- the exact local Docker image ID before staging:
1595
- IMAGE_TAG=ouro-butler:<version>
1596
- IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE_TAG")
1597
- printf '%s\n' "$IMAGE_ID" | grep -Eq '^sha256:[0-9a-f]{64}$'
1598
- docker image inspect "$IMAGE_ID" >/dev/null
2077
+ Before downloading or staging anything, classify the live source with the existing topology gates unless the fixed root-owned DockerMan transaction journal signals that an interrupted update needs the exact packaged recovery tools. Initial alpha.797 adoption must prove the exact staging name and image pin; normal updates must prove the existing production topology. A recovery journal admits only image acquisition and private staging until the staged transaction implementation validates the full journal and restores a classifiable source:
2078
+ admit_sanctuary_update_entry
2079
+ Set the released package version, its reviewed release-manifest digest, and its reviewed platform-local image ID. The canonical package-version tag is immutable release identity, while the digest and local ID independently prove what the registry and this host resolved:
2080
+ PACKAGE_VERSION=<released-version>
2081
+ MANIFEST_DIGEST=sha256:<reviewed-release-manifest-digest>
2082
+ IMAGE_ID=sha256:<reviewed-local-image-id>
2083
+ VERSION_IMAGE="ghcr.io/ourostack/ouroboros-butler:$PACKAGE_VERSION"
2084
+ printf '%s\n' "$PACKAGE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
2085
+ test "$(docker buildx imagetools inspect "$VERSION_IMAGE" --format '{{.Manifest.Digest}}')" = "$MANIFEST_DIGEST"
2086
+ docker pull "$VERSION_IMAGE"
2087
+ validate_exact_image_id "$IMAGE_ID"
2088
+ test "$(docker image inspect --format '{{.Id}}' "$VERSION_IMAGE")" = "$IMAGE_ID"
1599
2089
  AUDIT_RUNNER_IMAGE_ID=$IMAGE_ID
1600
2090
  validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
1601
- Before stopping, renaming, or creating any Butler container, extract the packaged event, template, and runtime-policy assets from that exact image ID. Install the event assets transactionally, bind the staged template to the exact local image ID, and audit both staged deployment files. Do not copy these files from a checkout or another image:
2091
+ TEMPLATE_ICON=https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
2092
+ DOCKERMAN_TEMPLATE_PATH=/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
2093
+ DOCKERMAN_TEMPLATE_JOURNAL=/boot/config/custom/ouro-butler/docker-man-template-transaction.json
2094
+ test "$DOCKERMAN_TEMPLATE_PATH" = /boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
2095
+ test "$DOCKERMAN_TEMPLATE_JOURNAL" = /boot/config/custom/ouro-butler/docker-man-template-transaction.json
2096
+ Before stopping, renaming, or creating any Butler container, extract the packaged event, template, runtime-policy, and DockerMan transaction assets from that exact image ID. Do not copy these files from a checkout or another image.
2097
+ Stage the event assets, audit the original version-tagged template, create a separate temporary copy for exact local-image-ID auditing, and keep the private stage until the outer transaction commits:
1602
2098
  EVENT_ASSET_STAGE=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/ouro-events.XXXXXX)
1603
2099
  chmod 0700 "$EVENT_ASSET_STAGE"
1604
2100
  EVENT_SCRIPT_STAGE="$EVENT_ASSET_STAGE/ouro-events"
1605
2101
  mkdir "$EVENT_SCRIPT_STAGE"
1606
2102
  STAGED_TEMPLATE="$EVENT_ASSET_STAGE/sanctuary.xml"
2103
+ STAGED_EXACT_TEMPLATE="$EVENT_ASSET_STAGE/sanctuary.exact-image.xml"
1607
2104
  STAGED_RUNTIME_POLICY="$EVENT_ASSET_STAGE/container-runtime.json"
2105
+ STAGED_DOCKERMAN_TRANSACTION="$EVENT_ASSET_STAGE/docker-man-template-transaction.mjs"
2106
+ STAGED_DOCKERMAN_XML_VALIDATOR="$EVENT_ASSET_STAGE/docker-man-template-xml.cjs"
1608
2107
  EVENT_ASSET_CONTAINER=
1609
2108
  cleanup_event_asset_stage() {
1610
2109
  if test -n "$EVENT_ASSET_CONTAINER"; then
@@ -1618,20 +2117,24 @@ Update:
1618
2117
  docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/ouro-events/." "$EVENT_SCRIPT_STAGE/"
1619
2118
  docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/sanctuary.xml" "$STAGED_TEMPLATE"
1620
2119
  docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/container-runtime.json" "$STAGED_RUNTIME_POLICY"
2120
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/docker-man-template-transaction.mjs" "$STAGED_DOCKERMAN_TRANSACTION"
2121
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/docker-man-template-xml.cjs" "$STAGED_DOCKERMAN_XML_VALIDATOR"
1621
2122
  docker rm "$EVENT_ASSET_CONTAINER"
1622
2123
  EVENT_ASSET_CONTAINER=
1623
- EXPECTED_RELEASE_ASSETS=$(printf '%s\n' container-runtime.json ouro-events sanctuary.xml)
2124
+ EXPECTED_RELEASE_ASSETS=$(printf '%s\n' container-runtime.json docker-man-template-transaction.mjs docker-man-template-xml.cjs ouro-events sanctuary.xml)
1624
2125
  ACTUAL_RELEASE_ASSETS=$(find "$EVENT_ASSET_STAGE" -mindepth 1 -maxdepth 1 -exec basename {} \; | LC_ALL=C sort)
1625
2126
  test "$ACTUAL_RELEASE_ASSETS" = "$EXPECTED_RELEASE_ASSETS"
1626
2127
  test -d "$EVENT_SCRIPT_STAGE" && test ! -L "$EVENT_SCRIPT_STAGE"
1627
2128
  test -f "$STAGED_TEMPLATE" && test ! -L "$STAGED_TEMPLATE"
1628
2129
  test -f "$STAGED_RUNTIME_POLICY" && test ! -L "$STAGED_RUNTIME_POLICY"
2130
+ test -f "$STAGED_DOCKERMAN_TRANSACTION" && test ! -L "$STAGED_DOCKERMAN_TRANSACTION"
2131
+ test -f "$STAGED_DOCKERMAN_XML_VALIDATOR" && test ! -L "$STAGED_DOCKERMAN_XML_VALIDATOR"
2132
+ chown 0:0 "$STAGED_TEMPLATE" "$STAGED_RUNTIME_POLICY" "$STAGED_DOCKERMAN_TRANSACTION" "$STAGED_DOCKERMAN_XML_VALIDATOR"
2133
+ chmod 0600 "$STAGED_TEMPLATE" "$STAGED_RUNTIME_POLICY" "$STAGED_DOCKERMAN_TRANSACTION" "$STAGED_DOCKERMAN_XML_VALIDATOR"
1629
2134
  EXPECTED_EVENT_ASSETS=$(printf '%s\n' bootstrap-spool.sh emit-event.mjs emit-usenet-event.sh install-usenet-guard.sh usenet-health.sh)
1630
2135
  ACTUAL_EVENT_ASSETS=$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort)
1631
2136
  test "$ACTUAL_EVENT_ASSETS" = "$EXPECTED_EVENT_ASSETS"
1632
2137
  test -z "$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 \( -type l -o ! -type f \) -print -quit)"
1633
- /bin/bash "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" --source-root "$EVENT_SCRIPT_STAGE"
1634
- /bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom
1635
2138
  verify_installed_usenet_guard() {
1636
2139
  test "$(findmnt -n -o FSTYPE --target /boot/config/custom)" = vfat
1637
2140
  test "$(stat -c '%u:%g:%a' /boot/config/custom/usenet_health.sh)" = 0:0:600
@@ -1661,62 +2164,53 @@ Update:
1661
2164
  INSTALLED_SPOOL_SIZE=$(findmnt -bn -o SIZE --target /boot/config/custom/ouro-events/spool)
1662
2165
  test "$INSTALLED_SPOOL_SIZE" -le 4194304
1663
2166
  }
1664
- verify_installed_usenet_guard
2167
+ docker run --rm --pull=never --network=none \
2168
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
2169
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
2170
+ --mount "type=bind,src=$STAGED_TEMPLATE,dst=/audit/sanctuary.xml,readonly" \
2171
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
2172
+ "$IMAGE_ID" --persistent-template /audit/sanctuary.xml --runtime-policy /audit/container-runtime.json --expected-image-reference "$VERSION_IMAGE"
1665
2173
  /usr/local/bin/node -e '
1666
2174
  const fs = require("node:fs");
1667
- const [templatePath, imageId] = process.argv.slice(1);
2175
+ const [sourcePath, destinationPath, imageId] = process.argv.slice(1);
1668
2176
  if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error("invalid exact image ID");
1669
- const source = fs.readFileSync(templatePath, "utf8");
2177
+ const source = fs.readFileSync(sourcePath, "utf8");
1670
2178
  const repositories = [...source.matchAll(/<Repository>[^<]*<\/Repository>/g)];
1671
2179
  if (repositories.length !== 1) throw new Error("template must contain exactly one Repository element");
1672
2180
  const staged = source.replace(repositories[0][0], `<Repository>${imageId}</Repository>`);
1673
2181
  if ((staged.match(/<Repository>[^<]*<\/Repository>/g) ?? []).length !== 1
1674
2182
  || !staged.includes(`<Repository>${imageId}</Repository>`)) throw new Error("exact Repository staging failed");
1675
- fs.writeFileSync(templatePath, staged);
1676
- ' "$STAGED_TEMPLATE" "$IMAGE_ID"
1677
- If extraction, transactional installation, boot activation, or verification fails, abort the update before any Butler mutation. The installer restores the previous assets, go file, and cron on transactional failure; production stays running. If boot activation or verification fails after the transaction commits, leave production untouched, repair or rerun this exact-image installation, and do not continue. Container rollback begins only after the later production stop succeeds.
1678
- Initial install/adoption is a separate terminal path for the verified live
1679
- legacy state: no production or rollback, exactly one running (possibly
1680
- unhealthy) ouro-butler-staging, and no legacy-evidence container. After the
1681
- helper definitions above are loaded and IMAGE_ID is resolved, run this exact
1682
- sequence. MiniMax credentials are imported from the byte-verified legacy
1683
- bootstrap envelope; do not place credentials in arguments, shell variables, or
1684
- history.
1685
- These provider-readiness commands are adoption-only; do not use them as a
1686
- normal-update precheck against an active canonical production daemon.
2183
+ fs.writeFileSync(destinationPath, staged, { mode: 0o600, flag: "wx" });
2184
+ ' "$STAGED_TEMPLATE" "$STAGED_EXACT_TEMPLATE" "$IMAGE_ID"
2185
+ chown 0:0 "$STAGED_EXACT_TEMPLATE"
2186
+ chmod 0600 "$STAGED_EXACT_TEMPLATE"
2187
+ docker run --rm --pull=never --network=none \
2188
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
2189
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
2190
+ --mount "type=bind,src=$STAGED_EXACT_TEMPLATE,dst=/audit/sanctuary.exact-image.xml,readonly" \
2191
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
2192
+ "$IMAGE_ID" --template /audit/sanctuary.exact-image.xml --runtime-policy /audit/container-runtime.json --expected-image "$IMAGE_ID"
2193
+ recover_dockerman_template_transaction
2194
+ recover_pending_sanctuary_bundle_migration "$IMAGE_ID"
2195
+ classify_sanctuary_update_source
2196
+ /bin/bash "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" --source-root "$EVENT_SCRIPT_STAGE"
2197
+ /bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom
2198
+ verify_installed_usenet_guard
2199
+ If extraction or transactional installation fails, abort before changing any live Butler container. The installer restores the previous assets, go file, and cron; production stays running.
2200
+ If boot activation or verification fails after that transaction commits, leave production untouched, repair or rerun this exact-image installation, and stop. Container and DockerMan rollback begin only after the later production preflight succeeds.
2201
+ Initial install/adoption is a separate terminal path for the verified older layout: no production or rollback, exactly one running (possibly unhealthy) ouro-butler-staging, and no legacy-evidence container. After the helpers above are loaded and IMAGE_ID is resolved, run this exact sequence.
2202
+ MiniMax credentials come from the byte-verified legacy bootstrap envelope. Never place credentials in arguments, shell variables, or history.
2203
+ These provider-readiness commands are adoption-only; do not use them as a normal-update precheck against an active canonical production daemon.
1687
2204
  Sanctuary legacy adoption commands:
1688
2205
  prepare_sanctuary_legacy_adoption "$IMAGE_ID"
1689
2206
  verify_sanctuary_provider_readiness "$IMAGE_ID"
1690
2207
  install_from_legacy_staging
1691
- These commands are one terminal path; after install succeeds, stop and do not
1692
- continue into the normal update below. The final install is noninteractive:
1693
- it reruns resumable preparation plus fresh readiness, but never authentication.
1694
- That function never applies the canonical auditor to the known-noncanonical
1695
- legacy container or restarts it. It verifies and pins the legacy image,
1696
- provisions the absent canonical roots from the exact target image's packaged
1697
- Sanctuary skeleton, proves required files plus exact ownership/modes, and runs
1698
- same-image vault bootstrap and provider readiness before durably snapshotting
1699
- both exact container and image inspect records. Any preparation, vault, or
1700
- readiness failure returns while legacy remains running with autostart
1701
- untouched. It then stops and rechecks the legacy container and renames it to
1702
- stopped ouro-butler-legacy-evidence. A fresh canonical staging container is
1703
- created from exact IMAGE_ID and canonical binds, audited, started, proven to
1704
- be the only running Butler, readiness-checked, then stopped and removed only
1705
- after its image is rechecked. Only then is fresh canonical production created,
1706
- audited, started, readiness-checked, and placed in bare production autostart.
1707
- Failure removes only partial target-image staging/production containers,
1708
- quarantines the exact legacy container without deletion or restart, leaves
1709
- autostart disabled, and propagates the original status.
1710
- For this cutover, preflight either the exact known alpha.742 two-mount source
1711
- or an already-canonical source before any autostart or container mutation.
1712
- Production must be the only running Butler poller;
1713
- staging must be absent; rollback may be absent or one stopped container with
1714
- the exact production image. A stopped legacy-evidence container is preserved.
1715
- Disable every Butler name in Unraid's array-autostart file and verify that
1716
- result before stopping production. First resolve and validate the exact image
1717
- ID of the known-good production container while it is still running, so a
1718
- lookup failure cannot strand a renamed container:
1719
- recover_pending_sanctuary_bundle_migration "$IMAGE_ID"
2208
+ These commands are one terminal path; after install succeeds, stop and do not continue into the normal update below. The final install is noninteractive: it reruns resumable preparation plus fresh readiness, but never authentication.
2209
+ That function never applies the package-managed target contract to the older alpha.797 container or restarts it. It first proves the pinned alpha.797 image and exact allowed legacy shape, creates the missing canonical roots from the reviewed target image, checks required files and permissions, and finishes vault plus provider preparation. Any failure leaves the old Butler running with autostart untouched.
2210
+ After preparation succeeds, it records the exact old container and image, installs the original version-tagged template under the crash journal, disables Butler autostart, stops and rechecks the old container, and renames it to stopped ouro-butler-legacy-evidence. The journal also binds Jellyfin's exact container ID, image ID, state, and restart count under its digest. It then creates one canonical production container from the reviewed version tag with --pull=never, audits it before start, proves it is the only running healthy Butler, enables only production autostart, proves the stopped rollback plus DockerMan and Community Apps recognition, and commits the template journal only while Jellyfin remains unchanged.
2211
+ If activation fails, it removes only a partial target container, preserves the stopped legacy evidence, restores the prior template, leaves autostart disabled, and returns the original failure.
2212
+ For normal updates, preflight accepts only the pinned alpha.742 two-mount source, the pinned pre-package-managed alpha.797 source, or an already package-managed canonical source before any autostart or live-container change. The two pinned exceptions may validate an old source, but can never authorize creation of a new target.
2213
+ Production must be the only running Butler poller; staging must be absent; rollback may be absent or one stopped container with the exact production image. A stopped legacy-evidence container is preserved. Disable every Butler name in Unraid's array-autostart file and verify that result before stopping production. First resolve and validate the exact image ID of the known-good production container while it is still running, so a lookup failure cannot strand a renamed container:
1720
2214
  /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
1721
2215
  test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
1722
2216
  test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
@@ -1729,32 +2223,22 @@ Update:
1729
2223
  (exit "$UPDATE_PREFLIGHT_STATUS")
1730
2224
  fi
1731
2225
  assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1732
- The earlier exact-image asset stage has already replaced the template's sole
1733
- Repository element with $IMAGE_ID. After the topology and source gates pass,
1734
- audit the staged template and runtime policy with the packaged auditor, then
1735
- remove the private stage before any Butler mutation:
1736
- docker run --rm --pull=never --network=none \
1737
- --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
1738
- --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
1739
- --mount "type=bind,src=$STAGED_TEMPLATE,dst=/audit/sanctuary.xml,readonly" \
1740
- --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
1741
- "$IMAGE_ID" --template /audit/sanctuary.xml --runtime-policy /audit/container-runtime.json --expected-image "$IMAGE_ID"
1742
- cleanup_event_asset_stage
1743
- trap - EXIT
2226
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" prepare --source-template "$STAGED_TEMPLATE" --version-tag "$VERSION_IMAGE" --manifest-digest "$MANIFEST_DIGEST" --rollback-image-id "$ROLLBACK_IMAGE_ID" --target-image-id "$IMAGE_ID" >/dev/null
1744
2227
  if provision_sanctuary_sab_credential "$IMAGE_ID" \
1745
2228
  && verify_sanctuary_sab_readiness "$IMAGE_ID" \
1746
2229
  && verify_sanctuary_telegram_readiness "$IMAGE_ID"; then
1747
2230
  :
1748
2231
  else
1749
2232
  PRECUTOVER_READINESS_STATUS=$?
2233
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
1750
2234
  (exit "$PRECUTOVER_READINESS_STATUS")
1751
2235
  fi
1752
- Guard the atomic autostart disable separately. If it fails, production has not
1753
- been touched and the captured status is propagated:
2236
+ Guard the atomic autostart disable separately. If it fails, production has not been touched and the captured status is propagated:
1754
2237
  if disable_butler_autostart; then
1755
2238
  :
1756
2239
  else
1757
2240
  AUTOSTART_DISABLE_STATUS=$?
2241
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
1758
2242
  (exit "$AUTOSTART_DISABLE_STATUS")
1759
2243
  fi
1760
2244
  Define the stale-rollback cleanup used by the preparation guard:
@@ -1775,33 +2259,12 @@ ouro-butler-rollback
1775
2259
  ;;
1776
2260
  esac
1777
2261
  }
1778
- Stop production, remove only a stopped stale rollback, rename the known-good
1779
- container, verify it remains stopped, and apply the exact target image's
1780
- package-managed bundle migration in one explicit preparation guard.
1781
- Package-managed files are exactly `provider-readiness.json`,
1782
- `tool-profiles.json`, `habits/sanctuary-health.md`, and the five canonical
1783
- files under `psyche/`. The migration also merges the three bundle-meta version
1784
- fields. It never installs or mutates steward policy authority. It preserves
1785
- agent.json, all steward policy and audit bytes, relationships, sessions, and
1786
- every other state path. Repeating it is a no-op.
1787
- Before its first managed write, the migrator atomically fsyncs one mode-0600
1788
- rollback record at `.sanctuary-package-managed-rollback.json`. That transient
1789
- record contains a verified SHA-256 digest over the exact prior bytes, modes,
1790
- and parent existence for those
1791
- files plus bundle-meta, bound to distinct exact rollback and target image IDs.
1792
- It is the migration transaction record, not a second bundle authority. Normal
1793
- managed-file failure restores it immediately; every
1794
- later container rollback restores it before the old container restarts, while
1795
- successful production readiness and autostart commit it. A killed updater is
1796
- reconciled from the same record before update topology preflight: partial new
1797
- containers are removed, interrupted atomic-write stages are discarded, the
1798
- exact prior bundle is restored idempotently, and only the image ID recorded in
1799
- the transaction may resume as production. A malformed record, wrong image,
1800
- or restore failure leaves autostart disabled and fails closed. Once target
1801
- readiness and autostart have passed, commit first durably renames the record;
1802
- a kill then keeps target production in place and the next run validates its
1803
- exact topology/readiness before finishing commit. Migration failure then enters the same exact container
1804
- rollback arm before target production starts:
2262
+ Stop production, remove only a stopped stale rollback, rename the known-good container, verify it remains stopped, and apply the exact target image's package-managed bundle migration in one explicit preparation guard.
2263
+ Package-managed files are exactly `provider-readiness.json`, `tool-profiles.json`, `habits/sanctuary-health.md`, and the five canonical files under `psyche/`. The migration also merges the three bundle-meta version fields. It never installs or mutates steward policy authority. It preserves agent.json, all steward policy and audit bytes, relationships, sessions, and every other state path. Repeating it is a no-op.
2264
+ Before its first managed write, the migrator atomically fsyncs one mode-0600 rollback record at `.sanctuary-package-managed-rollback.json`. That record binds the verified prior bytes, modes, and parent existence for every managed file plus bundle-meta to distinct exact rollback and target image IDs. It is the migration receipt, not a second bundle authority.
2265
+ A managed-file failure restores the prior bytes immediately. Every later container rollback also restores those bytes before the old container restarts, while retaining the receipt; only after the old production container is audited, ready, and back on autostart does `finalize-rollback` remove it without marking the new release committed.
2266
+ After an interrupted update, the same receipt removes partial new containers, discards only validated interrupted-write stages, restores the prior bundle idempotently, and permits only the recorded rollback image to resume as production. A malformed receipt, wrong image, or restore failure leaves autostart disabled and stops safely.
2267
+ Once target readiness and autostart pass, `commit` durably marks the receipt as committing. If interruption happens then, the target stays in place and the next run rechecks its exact topology and readiness before finishing. Migration failure enters the same container rollback arm before target production starts:
1805
2268
  if docker stop ouro-butler \
1806
2269
  && remove_stopped_rollback_if_present "$ROLLBACK_IMAGE_ID" \
1807
2270
  && docker rename ouro-butler ouro-butler-rollback \
@@ -1811,6 +2274,7 @@ ouro-butler-rollback
1811
2274
  :
1812
2275
  else
1813
2276
  PRODUCTION_PREPARATION_STATUS=$?
2277
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" verify-jellyfin >/dev/null
1814
2278
  if docker container inspect ouro-butler >/dev/null 2>&1; then
1815
2279
  if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1816
2280
  docker stop ouro-butler-rollback >/dev/null 2>&1 || true
@@ -1821,59 +2285,52 @@ ouro-butler-rollback
1821
2285
  ! docker container inspect ouro-butler-rollback >/dev/null 2>&1
1822
2286
  rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
1823
2287
  assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1824
- docker start ouro-butler
1825
- assert_only_running_butler ouro-butler
2288
+ start_only_butler_for_recovery
1826
2289
  wait_butler_ready ouro-butler
1827
2290
  enable_butler_autostart
1828
- migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
2291
+ finalize_sanctuary_bundle_rollback_if_retained "$IMAGE_ID"
1829
2292
  elif docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1830
2293
  docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1831
2294
  test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$ROLLBACK_IMAGE_ID"
1832
2295
  rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
1833
2296
  docker rename ouro-butler-rollback ouro-butler
1834
2297
  assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1835
- docker start ouro-butler
1836
- assert_only_running_butler ouro-butler
2298
+ start_only_butler_for_recovery
1837
2299
  wait_butler_ready ouro-butler
1838
2300
  enable_butler_autostart
1839
- migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
2301
+ finalize_sanctuary_bundle_rollback_if_retained "$IMAGE_ID"
1840
2302
  fi
2303
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
1841
2304
  (exit "$PRODUCTION_PREPARATION_STATUS")
1842
2305
  fi
1843
- Preparation failure therefore either restores the still-named exact production
1844
- after removing any stale rollback, or renames the exact stopped rollback back;
1845
- both recoveries revalidate, start, bounded-wait, atomically restore production-only
1846
- autostart, and propagate the original failure. If neither exact container can
1847
- be found, the failure propagates with Butler autostart disabled.
1848
- Do not start a target-image daemon between the production rename and final
1849
- production activation. The exact-image static audit, SAB readiness, and
1850
- vault-backed Telegram identity check have already passed before autostart or
1851
- container mutation. Provider and complete daemon readiness are exercised only
1852
- by the transactional production activation below, whose failure arm restores
1853
- and revalidates the exact prior production. This prevents a disposable daemon
1854
- from reconciling or claiming live external-event state before cutover.
1855
- Create and activate production from the same exact image ID and exact authority
1856
- in one explicit conditional so `set -eu` cannot exit before rollback. Only a
1857
- successful create, effective audit, start, stopped rollback assertion, and
1858
- bounded readiness wait may enable production autostart. Any failure captures
1859
- the activation status, safely removes a partially created new production if it
1860
- exists, restores the stopped rollback container, audits it against its exact
1861
- old image ID, proves it ready, atomically enables only production autostart,
1862
- and then propagates the original activation failure:
1863
- if docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
2306
+ Preparation failure therefore either restores the still-named exact production after removing any stale rollback, or renames the exact stopped rollback back. Both paths revalidate and start the old container, wait within the fixed bound, restore production-only autostart atomically, and return the original failure.
2307
+ If neither exact container can be found, the failure returns with Butler autostart disabled.
2308
+ Do not start a target-image daemon between the production rename and final production activation. The exact-image static audit, download-queue readiness, and vault-backed Telegram identity check have already passed before autostart or live-container changes.
2309
+ Provider and complete daemon readiness are exercised only by the transactional production activation below. Its failure arm restores and revalidates the exact prior production, so a disposable daemon cannot reconcile or claim live external-event state before cutover.
2310
+ Create and activate production from the same exact image ID and exact authority in one explicit conditional so `set -eu` cannot exit before rollback. Only a successful create, effective audit, start, stopped-rollback assertion, and bounded readiness wait may enable production autostart.
2311
+ On failure, capture the activation status, remove only a partially created new production container, restore and audit the stopped rollback against its exact old image ID, prove it ready, restore production-only autostart atomically, and return the original failure:
2312
+ if test "$(docker buildx imagetools inspect "$VERSION_IMAGE" --format '{{.Manifest.Digest}}')" = "$MANIFEST_DIGEST" \
2313
+ && test "$(docker image inspect --format '{{.Id}}' "$VERSION_IMAGE")" = "$IMAGE_ID" \
2314
+ && docker create --pull=never --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
2315
+ --label net.unraid.docker.managed=dockerman \
2316
+ --label "net.unraid.docker.icon=$TEMPLATE_ICON" \
1864
2317
  --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1865
2318
  --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1866
2319
  --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
1867
- "$IMAGE_ID" \
1868
- && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2320
+ "$VERSION_IMAGE" \
2321
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" canonical "$VERSION_IMAGE" "$TEMPLATE_ICON" \
2322
+ && assert_only_running_butler - \
1869
2323
  && docker start ouro-butler \
1870
2324
  && assert_only_running_butler ouro-butler \
2325
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" \
1871
2326
  && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
1872
2327
  && wait_butler_ready ouro-butler \
1873
- && enable_butler_autostart; then
2328
+ && enable_butler_autostart \
2329
+ && /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" mark-committing >/dev/null; then
1874
2330
  :
1875
2331
  else
1876
2332
  PRODUCTION_ACTIVATION_STATUS=$?
2333
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" verify-jellyfin >/dev/null
1877
2334
  if docker container inspect ouro-butler >/dev/null 2>&1; then
1878
2335
  docker stop ouro-butler >/dev/null 2>&1 || true
1879
2336
  PARTIAL_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
@@ -1886,17 +2343,23 @@ ouro-butler-rollback
1886
2343
  migrate_sanctuary_package_managed_bundle "$IMAGE_ID" rollback
1887
2344
  docker rename ouro-butler-rollback ouro-butler
1888
2345
  assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1889
- docker start ouro-butler
1890
- assert_only_running_butler ouro-butler
2346
+ start_only_butler_for_recovery
1891
2347
  wait_butler_ready ouro-butler
1892
2348
  enable_butler_autostart
1893
- migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
2349
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" finalize-rollback
2350
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
1894
2351
  (exit "$PRODUCTION_ACTIVATION_STATUS")
1895
2352
  fi
1896
2353
  migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1897
- Keep ouro-butler-rollback stopped until the new production container is proven
1898
- or the explicit rollback arm restores it.
1899
- Never create a container from the mutable lookup tag.
2354
+ FINAL_PROOF_ROOT="$EVENT_ASSET_STAGE/final-proof"
2355
+ FINAL_PROOF_PATH=$(write_dockerman_final_proof "$IMAGE_ID" "$VERSION_IMAGE" "$TEMPLATE_ICON" "$FINAL_PROOF_ROOT")
2356
+ verify_known_good_rollback_artifact "$ROLLBACK_IMAGE_ID"
2357
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" commit --proof "$FINAL_PROOF_PATH" >/dev/null
2358
+ cleanup_event_asset_stage
2359
+ trap - EXIT
2360
+ Keep ouro-butler-rollback stopped until the new production container is proven or the explicit rollback arm restores it. Never create production from a mutable tag, a bare tag, or a bare local image ID.
2361
+ Docker tab Update, Force Update, Edit/Apply, Update All, and CA Action Centre updates remain visible but are unsupported because stock recreation deletes reviewed rollback evidence. Visibility, start, stop, and autostart remain supported; use only this reviewed version-tag transaction for updates.
2362
+ Community Apps determines installed state from the DockerMan template plus the live container name and image. The helper proves that same relationship without calling the endpoint that refreshes Community Apps' UI cache; the later live UI smoke confirms what Ari sees.
1900
2363
 
1901
2364
  Backup:
1902
2365
  Set BACKUP_ROOT to a new absolute snapshot path on the destination filesystem.
@@ -2076,6 +2539,7 @@ Backup:
2076
2539
  BACKUP_OPERATION_STATUS=$?
2077
2540
  fi
2078
2541
  if assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2542
+ && assert_only_running_butler - \
2079
2543
  && docker start ouro-butler \
2080
2544
  && assert_only_running_butler ouro-butler \
2081
2545
  && wait_butler_ready ouro-butler \
@@ -2106,13 +2570,20 @@ Restore:
2106
2570
  legacy-aware auditor. The runner is independent from the restored image.
2107
2571
  AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
2108
2572
  AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
2109
- Before any autostart, root, or container mutation, run the nounset-safe input,
2110
- backup-root, image, and topology preflight. It requires a nonempty canonical
2111
- absolute BACKUP_ROOT (not /), both exact required directories, an exact local
2112
- sha256 image ID, canonical production as the only running Butler poller, no
2113
- staging or rollback, and at most one exact stopped legacy-evidence container.
2114
- It also executes the reviewed runner against the live source container before
2115
- any autostart, root, or container mutation:
2573
+ RESTORE_VERSION_IMAGE=$(/usr/local/bin/node -e '
2574
+ const fs = require("node:fs");
2575
+ const [containerPath, versionPath] = process.argv.slice(1);
2576
+ const containers = JSON.parse(fs.readFileSync(containerPath, "utf8"));
2577
+ const version = fs.readFileSync(versionPath, "utf8").trim();
2578
+ const expected = `ghcr.io/ourostack/ouroboros-butler:${version}`;
2579
+ if (!Array.isArray(containers) || containers.length !== 1 || containers[0]?.Config?.Image !== expected) process.exit(1);
2580
+ process.stdout.write(expected);
2581
+ ' "$BACKUP_ROOT/provenance/container-inspect.json" "$BACKUP_ROOT/provenance/package-version")
2582
+ test "$(docker image inspect --format '{{.Id}}' "$RESTORE_VERSION_IMAGE")" = "$IMAGE_ID"
2583
+ audit_registered_dockerman_template "$AUDIT_RUNNER_IMAGE_ID" "$RESTORE_VERSION_IMAGE"
2584
+ Before changing autostart, durable roots, or live containers, run the nounset-safe input, backup-root, image, and topology preflight. It requires a nonempty canonical absolute BACKUP_ROOT other than /, both required directories, an exact local sha256 image ID, canonical production as the only running Butler, no staging or rollback, and at most one exact stopped legacy-evidence container. It also audits the live source container with the reviewed runner.
2585
+ Restore never rewrites DockerMan registration outside the reviewed template transaction. The preflight therefore stops unless the persistent template already names the exact snapshot version, and the post-start check proves DockerMan and Community Apps still recognize it.
2586
+ If the template names another version, first run the same reviewed version-tag update transaction for the snapshot version, then rerun Restore. A mismatch stops before autostart, durable-root, or production-container changes:
2116
2587
  if assert_restore_preflight; then
2117
2588
  :
2118
2589
  else
@@ -2136,10 +2607,13 @@ Restore:
2136
2607
  /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
2137
2608
  test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
2138
2609
  test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
2610
+ RESTORE_INSTALL_PROOF_ROOT=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/restore-install-proof.XXXXXX)
2611
+ chmod 0700 "$RESTORE_INSTALL_PROOF_ROOT"
2139
2612
  if disable_butler_autostart; then
2140
2613
  :
2141
2614
  else
2142
2615
  RESTORE_AUTOSTART_DISABLE_STATUS=$?
2616
+ rmdir -- "$RESTORE_INSTALL_PROOF_ROOT"
2143
2617
  (exit "$RESTORE_AUTOSTART_DISABLE_STATUS")
2144
2618
  fi
2145
2619
  Stop and remove only ouro-butler, restore both roots without stale files,
@@ -2163,15 +2637,22 @@ Restore:
2163
2637
  && /bin/bash "$HOST_RESTORE_INSTALLER" --restore-root "$BACKUP_ROOT/host" \
2164
2638
  && rm -f "$HOST_RESTORE_INSTALLER" \
2165
2639
  && docker image inspect "$IMAGE_ID" >/dev/null \
2166
- && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
2640
+ && docker create --pull=never --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
2641
+ --label net.unraid.docker.managed=dockerman \
2642
+ --label "net.unraid.docker.icon=https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png" \
2167
2643
  --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
2168
2644
  --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
2169
2645
  --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
2170
- "$IMAGE_ID" \
2171
- && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2646
+ "$RESTORE_VERSION_IMAGE" \
2647
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" canonical "$RESTORE_VERSION_IMAGE" https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png \
2648
+ && assert_only_running_butler - \
2172
2649
  && docker start ouro-butler \
2173
2650
  && assert_only_running_butler ouro-butler \
2651
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" \
2174
2652
  && wait_butler_ready ouro-butler \
2653
+ && verify_dockerman_and_community_apps "$RESTORE_VERSION_IMAGE" "$RESTORE_INSTALL_PROOF_ROOT/install.json" \
2654
+ && rm -f -- "$RESTORE_INSTALL_PROOF_ROOT/install.json" \
2655
+ && rmdir -- "$RESTORE_INSTALL_PROOF_ROOT" \
2175
2656
  && enable_butler_autostart; then
2176
2657
  :
2177
2658
  else
@@ -2184,6 +2665,8 @@ Restore:
2184
2665
  fi
2185
2666
  ! docker container inspect ouro-butler >/dev/null 2>&1
2186
2667
  rm -f "$HOST_RESTORE_INSTALLER"
2668
+ rm -f -- "$RESTORE_INSTALL_PROOF_ROOT/install.json"
2669
+ rmdir -- "$RESTORE_INSTALL_PROOF_ROOT"
2187
2670
  (exit "$RESTORE_ACTIVATION_STATUS")
2188
2671
  fi
2189
2672
  A restore has no retained old container or pre-restore data root to roll back
@@ -2467,12 +2950,7 @@ Packaged Unit 16 acceptance execution:
2467
2950
  inode used by the one-shot for marker correlation.
2468
2951
 
2469
2952
  Audit and safety verification:
2470
- Inspect AgentBundles/sanctuary.ouro/state/approvals for durable approval and
2471
- restart-attempt receipts. Confirm no Docker socket/device/host-root mounts and
2472
- no published ports with docker inspect. Confirm Config.Image equals the exact
2473
- reviewed local image ID, not the build tag. The read key must reject Docker
2474
- stop and restart mutations; only the separate write key may perform the one
2475
- typed approved restart action.
2953
+ Inspect AgentBundles/sanctuary.ouro/state/approvals for durable approval and restart-attempt receipts. Confirm with docker inspect that the container has no Docker socket, device, host-root mount, or published port. Confirm `Config.Image` equals the canonical package-version tag and `.Image` equals the exact reviewed local image ID. The read key must reject Docker stop and restart mutations; only the separate write key may perform the one typed approved restart action.
2476
2954
  The packaged `unraid-key-rotate` command is the only canonical key-rotation
2477
2955
  authority. Its host launcher stops the exact production container and assumes
2478
2956
  trap-backed recovery responsibility before key mutation. It inventories the