[
  {
    "path": ".cci.jenkinsfile",
    "content": "// Documentation: https://github.com/coreos/coreos-ci/blob/main/README-upstream-ci.md\n\nproperties([\n    // abort previous runs when a PR is updated to save resources\n    disableConcurrentBuilds(abortPrevious: true)\n])\n\nbuildPod {\n    checkout scm\n    stage(\"Build\") {\n        shwrap(\"make build RELEASE=1\")\n    }\n\n    stage(\"Unit Test\") {\n        shwrap(\"make check RELEASE=1\")\n    }\n\n    stage(\"Install\") {\n        shwrap(\"make install RELEASE=1 DESTDIR=install\")\n        stash name: 'build', includes: 'install/**'\n    }\n\n}\n\ncosaPod(buildroot: true) {\n    checkout scm\n\n    unstash name: 'build'\n    cosaBuild(overlays: [\"install\"])\n}\n"
  },
  {
    "path": ".gemini/config.yaml",
    "content": "# This config mainly overrides `summary: false` by default\n# as it's really noisy.\nhave_fun: true\ncode_review:\n  disable: false\n  comment_severity_threshold: \"MEDIUM\"\n  max_review_comments: -1\n  pull_request_opened:\n    help: false\n    # Turned off by default\n    summary: false\n    code_review: true\nignore_patterns: []\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug.md",
    "content": "---\nname: Bug report\nabout: Report an issue\n---\n\n# Bug Report #\n\n## Environment ##\n\nWhat hardware/cloud provider/hypervisor is being used?\n\n## Expected Behavior ##\n\n## Actual Behavior ##\n\n## Reproduction Steps ##\n\n  1. ...\n  2. ...\n\n## Other Information ##\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature.md",
    "content": "---\nname: Feature request\nabout: Suggest an enhancement\n---\n\n# Feature Request #\n\n## Desired Feature ##\n\n## Example Usage ##\n\n## Other Information ##\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/release-checklist.md",
    "content": "---\nname: release checklist\nabout: release checklist template\ntitle: New release for zincati\nlabels: jira,kind/release\nwarning: |\n    ⚠️ Template generated by https://github.com/coreos/repo-templates; do not edit downstream\n---\n\n# Release process\n\nThis project uses [cargo-release][cargo-release] in order to prepare new releases, tag and sign the relevant git commit, and publish the resulting artifacts to [crates.io][crates-io].\nThe release process follows the usual PR-and-review flow, allowing an external reviewer to have a final check before publishing.\n\nIn order to ease downstream packaging of Rust binaries, an archive of vendored dependencies is also provided (only relevant for offline builds).\n\n## Requirements\n\nThis guide requires:\n\n * A web browser (and network connectivity)\n * `git`\n * [GPG setup][GPG setup] and personal key for signing\n * `cargo` (suggested: latest stable toolchain from [rustup][rustup])\n * `cargo-release` (suggested: `cargo install -f cargo-release`)\n * `cargo vendor-filterer` (suggested: `cargo install -f cargo-vendor-filterer`)\n * Write access to this GitHub project\n * A verified account on crates.io\n * Membership in the [Fedora CoreOS Crates Owners group](https://github.com/orgs/coreos/teams/fedora-coreos-crates-owners/members), which will give you upload access to crates.io\n\n## Release checklist\n\nThese steps show how to release version `x.y.z` on the `origin` remote (this can be checked via `git remote -av`).\nPush access to the upstream repository is required in order to publish the new tag and the PR branch.\n\n:warning:: if `origin` is not the name of the locally configured remote that points to the upstream git repository (i.e. `git@github.com:coreos/zincati.git`), be sure to assign the correct remote name to the `UPSTREAM_REMOTE` variable.\n\n- prepare environment:\n  - [ ] `RELEASE_VER=x.y.z`\n  - [ ] `UPSTREAM_REMOTE=origin`\n  - [ ] `git checkout -b pre-release-${RELEASE_VER}`\n\n- check `Cargo.toml` for unintended increases of lower version bounds:\n  - [ ] `git diff $(git describe --abbrev=0) Cargo.toml`\n\n- update all dependencies:\n  - [ ] `cargo update`\n  - [ ] `git add Cargo.lock && git commit -m \"cargo: update dependencies\"`\n\n- land the changes:\n  - [ ] PR the changes, get them reviewed, approved and merged\n\n- make sure the project is clean:\n  - [ ] Make sure `cargo-release` and `cargo-vendor-filterer` are up to date: `cargo install cargo-release cargo-vendor-filterer`\n  - [ ] `git checkout main && git pull ${UPSTREAM_REMOTE} main`\n  - [ ] `cargo vendor-filterer target/vendor`\n  - [ ] `cargo test --all-features --config 'source.crates-io.replace-with=\"vv\"' --config 'source.vv.directory=\"target/vendor\"'`\n  - [ ] `cargo clean`\n  - [ ] `git clean -fd`\n\n- create release commit on a dedicated branch and tag it (the commit and tag will be signed with the GPG signing key you configured):\n  - [ ] `git checkout -b release-${RELEASE_VER}`\n  - [ ] `cargo release --execute ${RELEASE_VER}` (and confirm the version when prompted)\n\n- open and merge a PR for this release:\n  - [ ] `git push ${UPSTREAM_REMOTE} release-${RELEASE_VER}`\n  - [ ] open a web browser and create a PR for the branch above\n  - [ ] make sure the resulting PR contains exactly one commit\n  - [ ] in the PR body, write a short changelog with relevant changes since last release\n  - [ ] get the PR reviewed, approved and merged\n\n- publish the artifacts (tag and crate):\n  - [ ] `git checkout v${RELEASE_VER}`\n  - [ ] verify that `grep \"^version = \\\"${RELEASE_VER}\\\"$\" Cargo.toml` produces output\n  - [ ] `git push ${UPSTREAM_REMOTE} v${RELEASE_VER}`\n  - [ ] `cargo publish`\n\n- assemble vendor archive:\n  - [ ] `cargo vendor-filterer --format=tar.gz --prefix=vendor target/zincati-${RELEASE_VER}-vendor.tar.gz`\n\n- publish this release on GitHub:\n  - [ ] find the new tag in the [GitHub tag list](https://github.com/coreos/zincati/tags), click the triple dots menu, and create a release for it\n  - [ ] copy in the changelog from the release PR\n  - [ ] upload `target/zincati-${RELEASE_VER}-vendor.tar.gz`\n  - [ ] record digests of local artifacts:\n    - `sha256sum target/package/zincati-${RELEASE_VER}.crate`\n    - `sha256sum target/zincati-${RELEASE_VER}-vendor.tar.gz`\n  - [ ] publish release\n\n- clean up the local environment (optional, but recommended):\n  - [ ] `cargo clean`\n  - [ ] `git checkout main`\n  - [ ] `git pull ${UPSTREAM_REMOTE} main`\n  - [ ] `git push ${UPSTREAM_REMOTE} :pre-release-${RELEASE_VER} :release-${RELEASE_VER}`\n  - [ ] `git branch -d pre-release-${RELEASE_VER} release-${RELEASE_VER}`\n\n- Fedora packaging:\n  - [ ] Review the proposed changes in the PR submitted by Packit in [Fedora](https://src.fedoraproject.org/rpms/rust-zincati/pull-requests).\n  - [ ] once the PR merges to rawhide, merge rawhide into the other relevant branches (e.g. f43) then push those, for example:\n    ```bash\n    git checkout rawhide\n    git pull --ff-only\n    git checkout f43\n    git merge --ff-only rawhide\n    git push origin f43\n    ```\n  - [ ] on each of those branches run `fedpkg build`\n  - [ ] once the builds have finished, submit them to [bodhi](https://bodhi.fedoraproject.org/updates/new), filling in:\n    - `rust-zincati` for `Packages`\n    - selecting the build(s) that just completed, except for the rawhide one (which gets submitted automatically)\n    - writing brief release notes like \"New upstream release; see release notes at `link to GitHub release`\"\n    - leave `Update name` blank\n    - `Type`, `Severity` and `Suggestion` can be left as `unspecified` unless it is a security release. In that case select `security` with the appropriate severity.\n    - `Stable karma` and `Unstable` karma can be set to `2` and `-1`, respectively.\n  - [ ] [submit a fast-track](https://github.com/coreos/fedora-coreos-config/actions/workflows/add-override.yml) for FCOS testing-devel\n  - [ ] [submit a fast-track](https://github.com/coreos/fedora-coreos-config/actions/workflows/add-override.yml) for FCOS next-devel if it is [open](https://github.com/coreos/fedora-coreos-pipeline/blob/main/next-devel/README.md)\n\n[cargo-release]: https://github.com/sunng87/cargo-release\n[rustup]: https://rustup.rs/\n[crates-io]: https://crates.io/\n[GPG setup]: https://docs.github.com/en/github/authenticating-to-github/managing-commit-signature-verification\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# Maintained in https://github.com/coreos/repo-templates\n# Do not edit downstream.\n\n# Updates are grouped together by ecosystem in a single PR. An update can be\n# removed from a combined update PR via comments to dependabot:\n# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates#managing-dependabot-pull-requests-for-grouped-updates-with-comment-commands\n\nversion: 2\nupdates:\n  - package-ecosystem: cargo\n    directory: /\n    schedule:\n      interval: monthly\n    open-pull-requests-limit: 10\n    labels:\n      - area/dependencies\n\n    groups:\n      build:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/containers.yml",
    "content": "---\nname: Containers\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\npermissions:\n  contents: read\n\njobs:\n  build-fcos:\n    name: \"Build in FCOS buildroot\"\n    runs-on: ubuntu-latest\n    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Build and test\n        run: make\n"
  },
  {
    "path": ".github/workflows/rust.yml",
    "content": "# Maintained in https://github.com/coreos/repo-templates\n# Do not edit downstream.\n\nname: Rust\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\npermissions:\n  contents: read\n\n# don't waste job slots on superseded code\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CARGO_TERM_COLOR: always\n  # Pinned toolchain for linting\n  ACTIONS_LINTS_TOOLCHAIN: 1.90.0\n\njobs:\n  tests-stable:\n    name: Tests, stable toolchain\n    runs-on: ubuntu-latest\n    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v6\n      - name: Install toolchain\n        uses: dtolnay/rust-toolchain@v1\n        with:\n          toolchain: stable\n      - name: Cache build artifacts\n        uses: Swatinem/rust-cache@v2\n      - name: cargo build\n        run: cargo build --all-targets\n      - name: cargo test\n        run: cargo test --all-targets\n      - name: cargo test (failpoints)\n        run: cargo test --all-targets --features failpoints\n  tests-release-stable:\n    name: Tests (release), stable toolchain\n    runs-on: ubuntu-latest\n    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v6\n      - name: Install toolchain\n        uses: dtolnay/rust-toolchain@v1\n        with:\n          toolchain: stable\n      - name: Cache build artifacts\n        uses: Swatinem/rust-cache@v2\n      - name: cargo build (release)\n        run: cargo build --all-targets --release\n      - name: cargo test (release)\n        run: cargo test --all-targets --release\n  tests-release-msrv:\n    name: Tests (release), minimum supported toolchain\n    runs-on: ubuntu-latest\n    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v6\n      - name: Detect crate MSRV\n        run: |\n          msrv=$(cargo metadata --format-version 1 --no-deps | \\\n              jq -r '.packages[0].rust_version')\n          echo \"Crate MSRV: $msrv\"\n          echo \"MSRV=$msrv\" >> $GITHUB_ENV\n      - name: Install toolchain\n        uses: dtolnay/rust-toolchain@v1\n        with:\n          toolchain: ${{ env.MSRV }}\n      - name: Cache build artifacts\n        uses: Swatinem/rust-cache@v2\n      - name: cargo build (release)\n        run: cargo build --all-targets --release\n      - name: cargo test (release)\n        run: cargo test --all-targets --release\n  linting:\n    name: Lints, pinned toolchain\n    runs-on: ubuntu-latest\n    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v6\n      - name: Install toolchain\n        uses: dtolnay/rust-toolchain@v1\n        with:\n          toolchain: ${{ env.ACTIONS_LINTS_TOOLCHAIN }}\n          components: rustfmt, clippy\n      - name: Cache build artifacts\n        uses: Swatinem/rust-cache@v2\n      - name: cargo fmt (check)\n        run: cargo fmt -- --check -l\n      - name: cargo clippy (warnings)\n        run: cargo clippy --all-targets -- -D warnings\n  tests-other-channels:\n    name: Tests, unstable toolchain\n    runs-on: ubuntu-latest\n    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel\n    continue-on-error: true\n    strategy:\n      matrix:\n        channel: [beta, nightly]\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v6\n      - name: Install toolchain\n        uses: dtolnay/rust-toolchain@v1\n        with:\n          toolchain: ${{ matrix.channel }}\n      - name: Cache build artifacts\n        uses: Swatinem/rust-cache@v2\n      - name: cargo build\n        run: cargo build --all-targets\n      - name: cargo test\n        run: cargo test --all-targets\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n**/*.rs.bk\nvendor\njsonnetfile.lock.json\ndashboard_out\n"
  },
  {
    "path": ".packit.yaml",
    "content": "# See the documentation for more information:\n# https://packit.dev/docs/configuration/\nactions:\n  changelog-entry:\n    - bash -c 'echo \"- New upstream release\"'\n  post-upstream-clone:\n    - wget https://src.fedoraproject.org/rpms/rust-zincati/raw/rawhide/f/rust-zincati.spec\n\nspecfile_path: rust-zincati.spec\ndownstream_package_name: rust-zincati\nupstream_package_name: zincati\nupstream_tag_template: v{version}\n# add or remove files that should be synced\nfiles_to_sync:\n    - rust-zincati.spec\n    - .packit.yaml\n\njobs:\n\n- job: propose_downstream\n  trigger: release\n  # https://packit.dev/docs/configuration#aliases\n  dist_git_branches:\n    - fedora-development\n    - fedora-latest-stable\n\n- job: koji_build\n  trigger: commit\n  dist_git_branches:\n    - fedora-development\n    - fedora-latest-stable\n\n- job: bodhi_update\n  trigger: commit\n  dist_git_branches:\n    - fedora-development\n    - fedora-latest-stable\n"
  },
  {
    "path": "COPYRIGHT",
    "content": "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: zincati\nSource: https://www.github.com/coreos/zincati\n\nFiles: *\nCopyright: 2019 Red Hat Inc.\nLicense: Apache-2.0\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"zincati\"\nversion = \"0.0.32\"\ndescription = \"Update agent for Fedora CoreOS\"\nhomepage = \"https://coreos.github.io/zincati\"\nlicense = \"Apache-2.0\"\nkeywords = [\"cincinnati\", \"coreos\", \"fedora\", \"rpm-ostree\"]\nauthors = [\"Luca Bruno <luca.bruno@coreos.com>\"]\nrepository = \"https://github.com/coreos/zincati\"\nedition = \"2021\"\nrust-version = \"1.90.0\"\n\n[dependencies]\nactix = \"0.13\"\nanyhow = \"1.0\"\ncfg-if = \"1.0\"\nchrono = { version = \"0.4.42\", features = [\"serde\"] }\nclap = { version = \"4.5\", features = [\"cargo\", \"derive\"] }\ncoreos-stream-metadata = \"0.1.0\"\nenv_logger = \"0.11\"\nenvsubst = \"0.2\"\nfail = \"0.5\"\nfiletime = \"0.2\"\nfn-error-context = \"0.2\"\nfutures = \"0.3\"\nglob = \"0.3\"\nintervaltree = \"0.2.7\"\nlazy_static = \"1.4\"\nlibc = \"0.2\"\nliboverdrop = \"0.1.0\"\nlibsystemd = \"0.7\"\nlog = \"0.4\"\nmaplit = \"1.0\"\nnum-traits = \"0.2\"\nonce_cell = \">= 1.19, < 1.30\"\nordered-float = { version = \"5.1\", features = [\"serde\"] }\nostree-ext = \"0.15.3\"\nprometheus = { version = \"0.14\", default-features = false }\nrand = \">=0.9, < 0.10\"\nregex = \"1.12\"\nreqwest = { version = \"0.12\", features = [\"json\"] }\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\ntempfile = \">= 3.7, < 4.0\"\nthiserror = \"2.0\"\ntokio = { version = \"1.48\", features = [\"signal\", \"rt\", \"rt-multi-thread\"] }\ntoml = \">= 0.8, < 0.10\"\ntzfile = \"0.1.3\"\nurl = { version = \"2.5\", features = [\"serde\"] }\nusers = \"0.11.0\"\nzbus = \"5.12.0\"\n\n[dev-dependencies]\nhttp = \"1.4\"\nmockito = \"1.7\"\nproptest = \"1.9\"\ntempfile = \">= 3.7, < 4.0\"\n\n[features]\nfailpoints = [ \"fail/failpoints\" ]\n\n[package.metadata.release]\npublish = false\npush = false\npre-release-commit-message = \"cargo: zincati release {{version}}\"\nsign-commit = true\nsign-tag = true\ntag-message = \"zincati {{version}}\"\n\n# See https://github.com/coreos/cargo-vendor-filterer\n[package.metadata.vendor-filter]\nplatforms = [\"*-unknown-linux-gnu\"]\ntier = \"2\"\nall-features = true\n"
  },
  {
    "path": "DCO",
    "content": "Developer Certificate of Origin\nVersion 1.1\n\nCopyright (C) 2004, 2006 The Linux Foundation and its contributors.\n660 York Street, Suite 102,\nSan Francisco, CA 94110 USA\n\nEveryone is permitted to copy and distribute verbatim copies of this\nlicense document, but changing it is not allowed.\n\n\nDeveloper's Certificate of Origin 1.1\n\nBy making a contribution to this project, I certify that:\n\n(a) The contribution was created in whole or in part by me and I\n    have the right to submit it under the open source license\n    indicated in the file; or\n\n(b) The contribution is based upon previous work that, to the best\n    of my knowledge, is covered under an appropriate open source\n    license and I have the right under that license to submit that\n    work with modifications, whether created in whole or in part\n    by me, under the same open source license (unless I am\n    permitted to submit under a different license), as indicated\n    in the file; or\n\n(c) The contribution was provided directly to me by some other\n    person who certified (a), (b) or (c) and I have not modified\n    it.\n\n(d) I understand and agree that this project and the contribution\n    are public and that a record of the contribution (including all\n    personal information I submit with it, including my sign-off) is\n    maintained indefinitely and may be redistributed consistent with\n    this project or the open source license(s) involved.\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "Makefile",
    "content": "RELEASE ?= 0\nTARGETDIR ?= target\n\nifeq ($(RELEASE),1)\n\tPROFILE ?= release\n\tCARGO_ARGS = --release\nelse\n\tPROFILE ?= debug\n\tCARGO_ARGS =\nendif\n\n.PHONY: all\nall: build check\n\n.PHONY: build\nbuild:\n\tcargo build \"--target-dir=${TARGETDIR}\" ${CARGO_ARGS}\n\n.PHONY: install\ninstall: build\n\tinstall -D -t ${DESTDIR}/usr/libexec \"${TARGETDIR}/${PROFILE}/zincati\"\n\tinstall -D -m 644 -t ${DESTDIR}/usr/lib/zincati/config.d dist/config.d/*.toml\n\tinstall -D -m 644 -t ${DESTDIR}/usr/lib/systemd/system dist/systemd/system/*.service\n\tinstall -D -m 644 -t ${DESTDIR}/usr/lib/sysusers.d dist/sysusers.d/*.conf\n\tinstall -D -m 644 -t ${DESTDIR}/usr/lib/tmpfiles.d dist/tmpfiles.d/*.conf\n\tinstall -D -m 644 -t ${DESTDIR}/usr/share/polkit-1/rules.d dist/polkit-1/rules.d/*.rules\n\tinstall -D -m 644 -t ${DESTDIR}/usr/share/polkit-1/actions dist/polkit-1/actions/*.policy\n\tinstall -D -m 644 -t ${DESTDIR}/usr/share/dbus-1/system.d dist/dbus-1/system.d/*.conf\n\tinstall -D -m 644 -t ${DESTDIR}/usr/bin dist/bin/*\n\n.PHONY: check\ncheck:\n\tcargo test \"--target-dir=${TARGETDIR}\" ${CARGO_ARGS}\n"
  },
  {
    "path": "README.md",
    "content": "# Zincati\n\n[![crates.io](https://img.shields.io/crates/v/zincati.svg)](https://crates.io/crates/zincati)\n\nZincati is an auto-update agent for Fedora CoreOS hosts.\n\nIt works as a client for [Cincinnati] and [rpm-ostree], taking care of automatically updating/rebooting machines.\n\nFeatures:\n * Agent for [continuous auto-updates][auto-updates], with support for phased rollouts\n * [Configuration][configuration] via TOML dropins and overlaid directories\n * Multiple [update strategies][updates-strategy] for finalization/reboot\n * Local [maintenance windows][strategy-periodic] on a weekly schedule for planned upgrades\n * Internal [metrics][metrics] exposed over a local endpoint in Prometheus format\n * [Logging][logging] with configurable priority levels\n * Support for complex update-graphs via [Cincinnati protocol][cincinnati-protocol] (with rollout wariness, barriers, dead-ends and more)\n * Support for [cluster-wide reboot orchestration][strategy-fleetlock], via an external lock-manager\n\n![cluster reboot graph](./docs/images/metrics.png)\n\n[Cincinnati]: https://github.com/openshift/cincinnati\n[rpm-ostree]: https://github.com/coreos/rpm-ostree\n\n[auto-updates]: ./docs/usage/auto-updates.md\n[configuration]: ./docs/usage/configuration.md\n[updates-strategy]: ./docs/usage/updates-strategy.md\n[strategy-periodic]: ./docs/usage/updates-strategy.md#periodic-strategy\n[metrics]: ./docs/usage/metrics.md\n[logging]: ./docs/usage/logging.md\n[cincinnati-protocol]: ./docs/development/cincinnati/protocol.md\n[strategy-fleetlock]: ./docs/usage/updates-strategy.md#lock-based-strategy\n"
  },
  {
    "path": "contrib/monitoring-mixins/README.md",
    "content": "# Requirements\n\nIn order to customize and generate monitoring artifacts, the following tools are required:\n\n * `jb` available at <https://github.com/jsonnet-bundler/jsonnet-bundler>.\n * `jsonnet` available at <https://github.com/google/jsonnet>.\n * `mixtool` available at <https://github.com/monitoring-mixins/mixtool>.\n\nFor more information, see <https://monitoring.mixins.dev/>.\n\n# Artifacts generation\n\nMonitoring artifacts can be generated from mixins in a few steps:\n\n```sh\n# Clean stale artifacts.\nrm -rf vendor/ generated/ jsonnetfile.lock.json\n\n# Fetch jsonnet libraries.\njb install\n\n# Generate Grafana dashboards.\nmixtool generate dashboards -d generated/dashboards/ mixin.libsonnet\n```\n"
  },
  {
    "path": "contrib/monitoring-mixins/dashboards/dashboards.libsonnet",
    "content": "local grafana = import 'github.com/grafana/grafonnet-lib/grafonnet/grafana.libsonnet';\nlocal dashboard = grafana.dashboard;\nlocal row = grafana.row;\nlocal prometheus = grafana.prometheus;\nlocal graphPanel = grafana.graphPanel;\n\n{\n  grafanaDashboards+:: {\n    'dashboard.json':\n      dashboard.new(\n        'Fedora CoreOS updates (Zincati)',\n        time_from='now-7d',\n      ).addTemplate(\n        {\n          current: {\n            text: 'Prometheus',\n            value: 'Prometheus',\n          },\n          hide: 0,\n          label: null,\n          name: 'datasource',\n          options: [],\n          query: 'prometheus',\n          refresh: 1,\n          regex: '',\n          type: 'datasource',\n        },\n      )\n      .addRow(\n        row.new(\n          title='Agent identity',\n        )\n        .addPanel(\n          graphPanel.new(\n            'OS versions',\n            datasource='$datasource',\n            decimalsY1=0,\n            format='short',\n            legend_alignAsTable=true,\n            legend_current=true,\n            legend_show=true,\n            legend_values=true,\n            min=0,\n            span=6,\n            stack=true,\n          )\n          .addTarget(prometheus.target(\n            'sum by(os_version) (zincati_identity_os_info)',\n            legendFormat='{{os_version}}'\n          ))\n        )\n        .addPanel(\n          graphPanel.new(\n            'Static rollout wariness',\n            datasource='$datasource',\n            format='short',\n            legend_show=true,\n            min=0,\n            span=6,\n          )\n          .addTarget(prometheus.target(\n            'zincati_identity_rollout_wariness != 0',\n            legendFormat='{{instance}}'\n          ))\n        )\n      )\n      .addRow(\n        row.new(\n          title='Agent details',\n        )\n        .addPanel(\n          graphPanel.new(\n            'Agent refresh period (p99)',\n            datasource='$datasource',\n            formatY1='s',\n            span=6,\n            min=0,\n          )\n          .addTarget(prometheus.target(\n            'quantile_over_time(0.99, (time() - zincati_update_agent_last_refresh_timestamp)[15m:])',\n            legendFormat='{{instance}}'\n          ))\n        )\n        .addPanel(\n          graphPanel.new(\n            'Cincinnati client error-rate',\n            datasource='$datasource',\n            span=6,\n            min=0,\n          )\n          .addTarget(prometheus.target(\n            'sum by (kind) (rate(zincati_cincinnati_update_checks_errors_total[5m]))',\n            legendFormat='kind: {{kind}}'\n          ))\n        )\n        .addPanel(\n          graphPanel.new(\n            'Deadends detected',\n            datasource='$datasource',\n            decimalsY1=0,\n            format='short',\n            legend_alignAsTable=true,\n            legend_current=true,\n            legend_show=true,\n            legend_values=true,\n            min=0,\n            span=6,\n            stack=true,\n          )\n          .addTarget(prometheus.target(\n            'sum by (os_version) ((zincati_cincinnati_booted_release_is_deadend) + on (instance) group_left(os_version) (0*zincati_identity_os_info))',\n            legendFormat='{{os_version}}'\n          ))\n        )\n      ),\n  },\n}\n"
  },
  {
    "path": "contrib/monitoring-mixins/jsonnetfile.json",
    "content": "{\n  \"version\": 1,\n  \"dependencies\": [\n    {\n      \"source\": {\n        \"git\": {\n          \"remote\": \"https://github.com/grafana/grafonnet-lib.git\",\n          \"subdir\": \"grafonnet\"\n        }\n      },\n      \"version\": \"8fb95bd89990e493a8534205ee636bfcb8db67bd\"\n    }\n  ],\n  \"legacyImports\": false\n}\n"
  },
  {
    "path": "contrib/monitoring-mixins/mixin.libsonnet",
    "content": "(import 'dashboards/dashboards.libsonnet')\n"
  },
  {
    "path": "dist/bin/zincati-update-now",
    "content": "#!/bin/bash\nset -euo pipefail\n\necho \"WARN: This command is experimental and subject to change.\" >&2\n\nif [ \"$EUID\" != \"0\" ]; then\n    echo \"ERROR: Must be root to run zincati-update-now\" >&2\n    exit 1\nfi\n\n# this should exist already, but in case\nmkdir -p /run/zincati/config.d\ncat > /run/zincati/config.d/99-update-now.toml << EOF\n[identity]\nrollout_wariness = 0.0\n[updates]\nstrategy = \"immediate\"\nEOF\n\ntouch /run/zincati/override-interactive-check\n\nsystemctl daemon-reload\nsystemctl restart zincati --no-block\n\necho \"INFO: Streaming Zincati and RPM-OSTree logs...\" >&2\nexec journalctl -f -u zincati -u rpm-ostreed --since now\n"
  },
  {
    "path": "dist/config.d/10-agent.toml",
    "content": "# Configure agent timing.\n[agent.timing]\n\n# Pausing interval between updates checks in steady mode, in seconds.\nsteady_interval_secs = 300\n"
  },
  {
    "path": "dist/config.d/10-auto-updates.toml",
    "content": "# Enable auto-updates.\n[updates]\n\n# Boolean to enable auto-update logic.\n# There is almost no case where disabling this is a good idea.\nenabled = true\n\n# Boolean to allow downgrading via updates logic.\n# This provides an additional safety net against rogue servers,\n# and allowing downgrades via Zincati is generally not recommended.\nallow_downgrade = false\n"
  },
  {
    "path": "dist/config.d/10-identity.toml",
    "content": "# Configure agent identity.\n[identity]\n\n# Node group, used for cluster-wide reboot orchestration (e.g. Airlock).\ngroup = \"default\"\n\n# Node ID, in sd-id128(3) format.\n# By default it is automatically computed as\n# `systemd-id128 machine-id -a de35106b6ec24688b63afddaa156679b`\n#node_uuid = \"<ID128>\"\n\n# Client wariness for throttled rollouts, as a floating point number.\n# Allowed values are within the range from `0.0` (very bold clients) to `1.0` (cautious clients), limits included.\n# By default, clients are arbitrarily throttled by the Cincinnati server.\n#rollout_wariness = 0.5\n"
  },
  {
    "path": "dist/config.d/30-updates-strategy.toml",
    "content": "# How to finalize updates.\n[updates]\n\n# String to customize update strategy.\n# Default strategy is to immediately finalize updates as soon as available,\n# and reboot the node.\nstrategy = \"immediate\"\n\n# Update strategy which uses an external reboot coordinator (FleetLock protocol).\n#strategy = \"fleet_lock\"\n# Base URL for the FleetLock service.\n#fleet_lock.base_url = \"https://fleet-lock.example.com/\"\n\n# Update strategy which uses a periodic schedule for reboot/maintenance\n# windows, on a weekly basis.\n#strategy = \"periodic\"\n# Example of reboot windows: weekend days, between 01:00 and 02:00 UTC.\n#[[updates.periodic.window]]\n#days = [ \"Sat\", \"Sun\" ]\n#start_time = \"01:00\"\n#length_minutes = 60\n"
  },
  {
    "path": "dist/config.d/50-fedora-coreos-cincinnati.toml",
    "content": "# Fedora CoreOS Cincinnati backend\n[cincinnati]\nbase_url= \"https://updates.coreos.fedoraproject.org\"\n"
  },
  {
    "path": "dist/dbus-1/system.d/org.coreos.zincati.conf",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?> <!-- -*- XML -*- -->\n\n<!DOCTYPE busconfig PUBLIC\n          \"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN\"\n          \"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd\">\n\n<busconfig>\n  <!-- Generally allow access only for introspection -->\n  <policy context=\"default\">\n    <allow send_destination=\"org.coreos.zincati\"\n           send_interface=\"org.freedesktop.DBus.Introspectable\"/>\n    <allow send_destination=\"org.coreos.zincati\"\n           send_interface=\"org.freedesktop.DBus.Peer\"/>\n    <allow send_destination=\"org.coreos.zincati\"\n           send_interface=\"org.freedesktop.DBus.Properties\"/>\n  </policy>\n\n  <!-- User 'zincati' is the service owner -->\n  <policy user=\"zincati\">\n    <allow own_prefix=\"org.coreos.zincati\"/>\n    <allow send_destination=\"org.coreos.zincati\"/>\n    <allow receive_sender=\"org.coreos.zincati\"/>\n  </policy>\n\n  <!-- User 'root' is allowed to call into the service -->\n  <policy user=\"root\">\n    <allow send_destination=\"org.coreos.zincati\"/>\n    <allow receive_sender=\"org.coreos.zincati\"/>\n  </policy>\n</busconfig>\n"
  },
  {
    "path": "dist/polkit-1/actions/org.coreos.zincati.deadend.policy",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE policyconfig PUBLIC\n \"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN\"\n \"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd\">\n<policyconfig>\n  <action id=\"org.coreos.zincati.deadend\">\n    <description>Write dead-end release information as an MOTD fragment via Zincati</description>\n    <defaults>\n      <allow_any>no</allow_any>\n      <allow_inactive>no</allow_inactive>\n      <allow_active>no</allow_active>\n    </defaults>\n    <annotate key=\"org.freedesktop.policykit.exec.path\">/usr/libexec/zincati</annotate>\n    <annotate key=\"org.freedesktop.policykit.exec.argv1\">deadend-motd</annotate>\n  </action>\n</policyconfig>\n"
  },
  {
    "path": "dist/polkit-1/rules.d/zincati.rules",
    "content": "// Allow Zincati to deploy, finalize, and cleanup a staged deployment through rpm-ostree.\npolkit.addRule(function(action, subject) {\n    if (action.id == \"org.projectatomic.rpmostree1.deploy\" ||\n        action.id == \"org.projectatomic.rpmostree1.rebase\" ||\n        action.id == \"org.projectatomic.rpmostree1.finalize-deployment\" ||\n        action.id == \"org.projectatomic.rpmostree1.cleanup\") {\n        if (subject.user == \"zincati\") {\n            return polkit.Result.YES;\n        }\n    }\n});\n\n// Allow Zincati to write dead-end release information as an MOTD fragment.\npolkit.addRule(function(action, subject) {\n    if (action.id == \"org.coreos.zincati.deadend\" &&  \n        subject.user == \"zincati\") {\n        return polkit.Result.YES;\n    }\n});\n\n"
  },
  {
    "path": "dist/systemd/system/zincati.service",
    "content": "[Unit]\nDescription=Zincati Update Agent\nDocumentation=https://github.com/coreos/zincati\n# Skip live systems not meant to be auto-updated (e.g. live PXE, live ISO)\nConditionPathExists=!/run/ostree-live\nAfter=network.target\n# Wait for the boot to be marked as successful. In cluster contexts,\n# this prevents rolling out broken updates to all nodes in the fleet.\nRequires=boot-complete.target\nAfter=multi-user.target boot-complete.target\n# Make sure we don't inadvertently reboot the system before a machine-id is\n# created so that we don't cause ConditionFirstBoot=true units to run twice\n# See discussions in https://github.com/systemd/systemd/issues/4511.\nAfter=systemd-machine-id-commit.service\n\n[Service]\nUser=zincati\nGroup=zincati\nSupplementaryGroups=tty\nEnvironment=ZINCATI_VERBOSITY=\"-v\"\nType=notify\nExecStart=/usr/libexec/zincati agent ${ZINCATI_VERBOSITY}\nRestart=on-failure\nRestartSec=10s\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "dist/sysusers.d/50-zincati.conf",
    "content": "# Zincati - https://github.com/coreos/zincati\n# Type  Name     ID  GECOS\nu      zincati  -   \"Zincati user for auto-updates\"\n"
  },
  {
    "path": "dist/tmpfiles.d/zincati.conf",
    "content": "#Type Path                   Mode User    Group    Age Argument\nd     /run/zincati           0775 zincati zincati  -   -\n\n# Runtime configuration fragments\nd     /run/zincati/config.d  0775 zincati zincati  -   -\n\n# Runtime state, unstable/private implementation details\nd     /run/zincati/private   0770 zincati zincati  -   -\n\n# Runtime public interfaces\nd     /run/zincati/public    0775 zincati zincati  -   -\n\n# Legacy symlink to metrics socket\nL+    /run/zincati/private/metrics.promsock  - - - - ../public/metrics.promsock\n"
  },
  {
    "path": "docs/_config.yml",
    "content": "# Template generated by https://github.com/coreos/repo-templates; do not edit downstream\n\n# To test documentation changes locally or using GitHub Pages, see:\n# https://github.com/coreos/fedora-coreos-tracker/blob/main/docs/testing-project-documentation-changes.md\n\ntitle: Zincati\ndescription: Zincati documentation\nbaseurl: \"/zincati\"\nurl: \"https://coreos.github.io\"\npermalink: /:title/\nmarkdown: kramdown\nkramdown:\n  typographic_symbols:\n    ndash: \"--\"\n    mdash: \"---\"\n\nremote_theme: just-the-docs/just-the-docs@v0.12.0\nplugins:\n  - jekyll-remote-theme\n\ncolor_scheme: coreos\n\n# Aux links for the upper right navigation\naux_links:\n  \"Zincati on GitHub\":\n    - \"https://github.com/coreos/zincati\"\n\nfooter_content: \"Copyright &copy; <a href=\\\"https://www.redhat.com\\\">Red Hat, Inc.</a> and <a href=\\\"https://github.com/coreos\\\">others</a>.\"\n\n# Footer last edited timestamp\nlast_edit_timestamp: true\nlast_edit_time_format: \"%b %e %Y at %I:%M %p\"\n\n# Footer \"Edit this page on GitHub\" link text\ngh_edit_link: true\ngh_edit_link_text: \"Edit this page on GitHub\"\ngh_edit_repository: \"https://github.com/coreos/zincati\"\ngh_edit_branch: \"main\"\ngh_edit_source: docs\ngh_edit_view_mode: \"tree\"\n\ncompress_html:\n  clippings: all\n  comments: all\n  endings: all\n  startings: []\n  blanklines: false\n  profile: false\n"
  },
  {
    "path": "docs/_sass/color_schemes/coreos.scss",
    "content": "$link-color: #53a3da;\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "---\nnav_order: 9\n---\n\n# Contributing\n\n## Project architecture\n\n[Development doc-pages][devdocs] cover several aspects of this project, both at low-level (code and logic) and high-level (architecture and design).\n\n[devdocs]: development.md\n\n## Release process\n\nReleases can be performed by [creating a new release ticket][new-release-ticket] and following the steps in the checklist there.\n\n[new-release-ticket]: https://github.com/coreos/zincati/issues/new?labels=kind/release&template=release-checklist.md\n"
  },
  {
    "path": "docs/development/agent-actor-system.md",
    "content": "---\nnav_order: 2\nparent: Development\n---\n\n# Actor model and agent subsystems\n\nThe Zincati `agent` command provides a long-running background service which drives the OS through auto-updates.\nIt comprises several logical subsystems which can run in parallel and are arranged following the [actor model][wiki-actors].\n\nThe goal of this design is manifold:\n * it allows splitting logical subsystems into separate failure domains.\n * it models asynchronous operations and parallel components in an explicit way.\n * it minimizes the amount of shared state and locks, minimizing the chances of deadlocks and concurrency bugs.\n\n[wiki-actors]: https://en.wikipedia.org/wiki/Actor_model\n\n## Actor system\n\nThe core of the agent service is built on top of [Actix][actix], an asynchronous actor framework.\nThe only exception is the initialization logic (i.e. CLI flags and configuration files parsing), which is performed in a synchronous way and only once at service startup.\n\n[actix]: https://github.com/actix/actix\n\nA general overview on such actor framework is provided by the [Actix book][actix-book], and further API details are covered by its [documentation](docs-rs-actix).\nThere is also a companion web-framework called `actix-web`, which is however not relevant in this context.\n\n[actix-book]: https://actix.rs/book/actix/\n[docs-rs-actix]: https://docs.rs/actix\n\nThe agent is split into several actors, which encapsulate state and behavior, as shown in the diagram below.\nThey can run in parallel and communicate by exchanging messages.\n\n![actors diagram](../images/zincati-actors.png)\n\n### Metrics service\n\nThe \"metrics service\" actor is responsible for serving client requests on the local [metrics endpoint][usage-metrics].\nIt does not exchange message with other actors, and it asynchronously processes the stream of incoming connections.\n\nOnce accepted, each new client connection is represented as a `Connection` message.\n\n[usage-metrics]: ../usage/metrics.md\n\n### Update agent\n\nThe \"update agent\" actor contains the core logic of Zincati agent.\n\nThis actor manages the Finite State Machine (FSM) which supervises the auto-update flow.\nA time-based ticker drives the state machine. Timer ticks are represented by `RefreshTick` messages, which are sent by the actor to itself (with a delay) at each iteration.\n\nThis actor interacts with some local subsystems (e.g. the underlying Operating System) and also with remote ones (e.g. the Cincinnati service).\nIn general, operations which cannot be performed in a non-blocking way are delegated to other dedicated actors (e.g. rpm-ostree tasks).\n\n### Rpm-ostree client\n\nThe \"rpm-ostree client\" actor is responsible for shelling out to the `rpm-ostree` command, in order to interact with the rpm-ostree daemon.\nAs client commands require blocking and may take a long time to complete, this entity is implemented as a synchronous actor.\n\nThis actor bridges incoming requests (messages) to rpm-ostree actions (CLI commands):\n * `QueryLocalDeployments` maps to `rpm-ostree status`.\n * `FinalizeDeployment` maps to `rpm-ostree finalize-deployment`.\n * `StageDeployment` maps to `rpm-ostree deploy --lock-finalization`.\n\nThose actions are generally requested by the core \"update agent\" actor via the relevant message, and (processed) results are sent back to it once the task has completed.\n"
  },
  {
    "path": "docs/development/cincinnati/protocol.md",
    "content": "---\ntitle: Cincinnati for Fedora CoreOS\nparent: Development\nnav_order: 3\n---\n\n# Cincinnati for Fedora CoreOS\n\nCincinnati is a protocol to provide \"update hints\" to clients, and it builds upon experiences with the [Omaha update protocol][google-omaha].\nIt describes a particular method for representing transitions between releases of a project, allowing clients to apply updates in the right order.\n\n[google-omaha]: https://github.com/google/omaha/blob/v1.3.33.7/doc/ServerProtocolV3.md\n\n## Update Graph\n\nCincinnati uses a [directed acyclic graph][dag] (DAG) to represent the complete set of valid update-paths.\nEach node in the graph is a release (with payload details) and each directed edge is a valid transition.\n\n[dag]: https://en.wikipedia.org/wiki/Directed_acyclic_graph\n\n## Clients\n\nCincinnati clients are the final consumers of the update graph and payloads.\nA client periodically queries the Cincinnati service in order to fetch updates hints.\nOnce it discovers at least a valid update edge, it may or may not decide to apply it locally (based on its configuration and heuristic).\n\n## Graph API\n\n### Request\n\nHTTP `GET` requests are used to fetch the DAG (as a JSON object) from the Graph API endpoint.\nRequests SHOULD be sent to the Graph API endpoint at `/v1/graph` and MUST include the following header:\n\n```\nAccept: application/json\n```\n\nFedora CoreOS clients MUST provide additional details as URL query parameters in the request.\n\n|        Key       | Optional | Description                                           |\n|------------------|----------|-------------------------------------------------------|\n| basearch         | required | base architecture (non-empty string)                  |\n| stream           | required | client-selected update stream (non-empty string)      |\n| node_uuid        | optional | application-specific unique-identifier for the client |\n| os_version       | optional | current OS version                                    |\n| os_checksum      | optional | current OS checksum                                   |\n| group            | optional | update group                                          |\n| rollout_wariness | optional | client wariness to update rollout                     |\n| platform         | optional | client platform                                       |\n\n### Response\n\nA positive response to the `/v1/graph` endpoint MUST be a JSON representation of the update graph.\nEach known release is represented by an entry in the top-level `nodes` array.\nEach of these entries includes the release version label, a payload identifier and any additional metadata. Each entry follows this schema:\n\n|    Key   | Optional | Description                                                                             |\n|----------|----------|-----------------------------------------------------------------------------------------|\n| version  | required | the version of the release, as a unique (across \"nodes\" array) non-empty JSON string    |\n| payload  | required | payload identifier, as a JSON string                                                    |\n| metadata | required | a string-\\>string map conveying arbitrary information about the release                 |\n\nAllowed transitions between releases are represented as a top-level `edges` array, where each entry is an array-tuple.\nEach of these tuples has two fields: the index of the starting node, and the index of the target node. Both are non-negative integers, ranging from 0 to `len(nodes)-1`.\n\nFor an example of a valid JSON document from a graph response, see [response.json](./response.json).\n\n### Errors\n\nErrors on the `/v1/graph` endpoint SHOULD be returned to the client as JSON objects, with a 4xx or 5xx HTTP status code.\nError values carry a type-identifier and a textual description, according to the following schema:\n\n|  Key   | Optional | Description                                                  |\n|--------|----------|--------------------------------------------------------------|\n| kind   | required | error type identifier, as a non-empty JSON string            |\n| value  | required | human-friendly error description, as a non-empty JSON string |\n\n"
  },
  {
    "path": "docs/development/cincinnati/response.json",
    "content": "{\n  \"nodes\": [\n    {\n      \"version\": \"32.20200517.1.0\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.releases.age_index\": \"0\",\n        \"org.fedoraproject.coreos.scheme\": \"checksum\"\n      },\n      \"payload\": \"7c23c4735fb3c541586f0a4d3ca956ef93ef7d76f00a19bccf51460bafa7ee97\"\n    },\n    {\n      \"version\": \"32.20200601.1.0\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.scheme\": \"checksum\",\n        \"org.fedoraproject.coreos.releases.age_index\": \"1\"\n      },\n      \"payload\": \"8cffe35be831fa2601d315002cb39fb22509a4e7d3db10e61f880523f69b3bf6\"\n    },\n    {\n      \"version\": \"32.20200601.1.1\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.releases.age_index\": \"2\",\n        \"org.fedoraproject.coreos.scheme\": \"checksum\",\n        \"org.fedoraproject.coreos.updates.start_epoch\": \"1591279200\",\n        \"org.fedoraproject.coreos.updates.start_value\": \"0\",\n        \"org.fedoraproject.coreos.updates.rollout\": \"true\",\n        \"org.fedoraproject.coreos.updates.duration_minutes\": \"2880\"\n      },\n      \"payload\": \"08040bebbab87a3343a281f94bb68010df618eb6ce6ac3d4230d2595959b5da1\"\n    }\n  ],\n  \"edges\": [\n    [\n      0,\n      2\n    ],\n    [\n      1,\n      2\n    ]\n  ]\n}\n"
  },
  {
    "path": "docs/development/fleetlock/protocol.md",
    "content": "---\nparent: Development\nnav_order: 4\n---\n\n# FleetLock protocol\n\nThis document describes an HTTP-based protocol for orchestrating fleet-wide reboots, used by Zincati.\nIt is modeled after a distributed counting semaphore with recursive locking and lock-ownership.\n\n## Overview\n\nThe FleetLock protocol is a request-response protocol where operations are always initiated by the client (i.e. Zincati).\nEach operation consists of a JSON payload sent as a POST request to the server.\n\nAt an high level, the client can perform two operations:\n\n * `RecursiveLock`: try to reserve (lock) a slot for rebooting\n * `UnlockIfHeld`: try to release (unlock) a slot that it was previously holding\n\nSemaphore locks are owned, so that only the client that created a lock can release it.\nAll operations are recursive, meaning that multiple unbalanced lock/unlock actions by a client are allowed.\n\n## Client state-machine\n\nClients start off in one of two states based on the system condition: \"initialization\" or \"finalization\". There are a number of states between \"initialization\" and \"finalization\" as well.\nIn the \"**initialization**\" state, the client tries to release any reboot slot it may have previously held.\nA successful unlock operation means that the client can proceed into its \"**steady**\" state and look for further updates.\nWhen an update is found and locally staged, the client proceed into its \"**pre-reboot**\" state and tries to lock a reboot slot.\nA successful lock operation means that the client can proceed into its \"**finalization**\" state and finalize a pending update, then reboot.\n\n## Requests\n\n### Endpoints\n\nAll endpoints defined below are relative to a common deployment-specific base URL:\n\n * `/v1/pre-reboot`: reserve/lock a reboot slot\n * `/v1/steady-state`: release/unlock a reboot slot\n\n### Body\n\nAll POST requests contain well-formed JSON body according to the following schema:\n\n * `client_params` (object, mandatory)\n   * `id` (string, mandatory, non-empty): client identifier (e.g. node name or UUID)\n   * `group` (string, mandatory, non-empty): reboot-group of the client\n\nClient ID is a case-sensitive textual label that uniquely identifies a lock holder. It is generated and persisted by each client.\nClient group is a mandatory textual label, conforming to the regexp `^[a-zA-Z0-9.-]+$`. This labels can be configured on each client. A server SHOULD check this value and MAY use it to provide multiple reboot buckets (sorting a fleet of nodes into reboot tiers).\n\nBy default, Zincati uses the group name \"`default`\" unless explicitly configured otherwise.\n\n### Headers\n\nLocking and unlocking requests must contain a `fleet-lock-protocol` header with a fixed value of `true` to ensure that the actual request was directly intended and not a part of unintentional redirection.\n\n### Response\n\nIf the operation is succesful, a 200 status code is returned. Every other code is considered as a failed operation.\n\n### Example\n\nA client with UUID `c988d2509fdf4cdcbed39037c56406fb` and group `workers` can try to acquire a reboot slot from `https://example.com/base` in a way which is conceptually similar to the following:\n\nRequest body:\n\n```json\n\n{\n  \"client_params\": {\n    \"group\": \"workers\",\n    \"id\": \"c988d2509fdf5cdcbed39037c56406fb\"\n  }\n}\n\n```\n\nPOST request:\n\n```shell\n\ncurl -H \"fleet-lock-protocol: true\" -d @body.json http://example.com/base/v1/pre-reboot\n\n```\n\n### Errors\n\nErrors on the service endpoints SHOULD be returned to the client as JSON objects, with a 4xx or 5xx HTTP status code.\nError values carry a type-identifier and a textual description, according to the following schema:\n\n|  Key   | Optional | Description                                                  |\n|--------|----------|--------------------------------------------------------------|\n| kind   | required | error type identifier, as a non-empty JSON string            |\n| value  | required | human-friendly error description, as a non-empty JSON string |\n\nThis allows clients to show more specific error details to cluster administrators, instead of generic HTTP errors.\n\nFor example, an error value like the following could be returned on `/v1/pre-reboot` when all available slots are already in use:\n\n```json\n{\n  \"kind\": \"failed_lock_semaphore_full\",\n  \"value\": \"semaphore currently full, all slots are locked already\"\n}\n```\n\nZincati will log this error using the content of `value`, and it will track the `kind` label in metrics.\n\nA server MUST ensure that possible values for `kind` have a bounded/small cardinality.\n"
  },
  {
    "path": "docs/development/os-metadata.md",
    "content": "---\nnav_order: 5\nparent: Development\n---\n\n# OS metadata and agent identity\n\nThe agent needs to derive its own identity from several aspects of the underlying OS.\nIn order to do so, at startup it performs run-time introspection of current machine state and OS metadata.\n\nThe following details are derived from the host environment:\n\n * application-specific node UUID\n * base architecture\n * update stream\n * OS platform\n * OS version\n * OSTree revision\n\nIt is thus required that the OS provides those values in the locations described below.\n\n### Kernel command-line\n\nKernel command-line must contain a `ignition.platform.id=<VALUE>` argument. The literal value is used as the \"OS platform\".\n\n### rpm-ostree deployment status\n\nBooted deployment must provide several mandatory metadata entries:\n\n * `checksum`: OSTree commit revision\n * `version`: OS version\n * under `base-commit-meta`:\n   * `fedora-coreos.stream`: update stream\n\nAll those metadata entries must exist with a non-empty string value.\n\n### Filesystem\n\nFilesystem must provide a `/etc/machine-id` file, as specified by [machine-id spec][machine-id]. Its value is used to derive the application-specific node UUID.\n\n[machine-id]: https://www.freedesktop.org/software/systemd/man/machine-id.html\n"
  },
  {
    "path": "docs/development/quickstart.md",
    "content": "---\nnav_order: 1\nparent: Development\n---\n\n# Development quickstart\n\nThis is quick start guide for developing and building this project from source on a Linux machine.\n\n## Get the source\n\nThe canonical development location of this project is on GitHub. You can fetch the full source with history via `git`:\n\n```sh\ngit clone https://github.com/coreos/zincati.git\ncd zincati\n```\n\nIt is recommend to fork a copy of the project to your own GitHub account, and add it as an additional remote:\n\n```sh\ngit remote add my-fork git@github.com:<YOURUSER>/zincati.git\n```\n\n## Install Rust toolchain\n\nThis project is written in Rust, and requires a stable toolchain to build. Additionally, `clippy` and `rustfmt` are used by CI jobs to ensure that patches are properly formatted and linted.\n\nYou can obtain a Rust toolchain via many distribution methods, but the simplest way is via [rustup](https://rustup.rs/):\n\n```sh\nrustup component add clippy\nrustup component add rustfmt\nrustup install stable\n```\n\n## Build and test\n\nBuilding and testing is handled via `cargo` and `make`:\n\n```sh\nmake build\nmake check\n```\n\nIf you prefer running builds in a containerized environment, you can use the FCOS buildroot image at `quay.io/coreos-assembler/fcos-buildroot:testing-devel`:\n\n```sh\ndocker pull quay.io/coreos-assembler/fcos-buildroot:testing-devel\ndocker run --rm -v \"$(pwd):/source:z\" quay.io/coreos-assembler/fcos-buildroot:testing-devel bash -c \"cd source; make\"\n```\n\nThe FCOS buildroot image is the same image that is used by integration jobs in CI.\nIt contains all the required dependencies and can be used to build other CoreOS projects too (not only Zincati).\n\n## Assemble custom OS images\n\n`coreos-assembler` ([`cosa`](https://github.com/coreos/coreos-assembler)) makes it very handy to embed build artifacts in a custom OS image, in order to test patches in the final environment.\n\nOnce a new `cosa` workspace has been initialized, you can place the binaries in the `overrides/` directory before building your custom image:\n\n```sh\npushd /tmp\nmkdir test-image\ncd test-image\ncosa init https://github.com/coreos/fedora-coreos-config\npopd\ndocker run --rm -v \"$(pwd):/source:z\" -v \"/tmp/test-image:/assembler:z\" \\\n    -e DESTDIR=\"/assembler/overrides/rootfs\" -e TARGETDIR=\"/assembler/tmp/zincati/target\" \\\n    quay.io/coreos-assembler/fcos-buildroot:testing-devel bash -c \"cd source; make install\"\npushd /tmp/test-image\ncosa fetch\ncosa build\n```\n\nFor more details, see `coreos-assembler` [overrides documentation](https://coreos.github.io/coreos-assembler/working/#using-overrides).\n\n### `build-fast` for faster iteration\n\nIt is possible to use the CoreOS Assembler's [`build-fast`][build-fast-cmd] command for faster iteration.\nSee [here][build-fast-instructions] for instructions on fast-building a qemu image for testing.\n\n[build-fast-cmd]: https://github.com/coreos/coreos-assembler/blob/main/src/cmd-build-fast\n[build-fast-instructions]: https://github.com/coreos/coreos-assembler/blob/2f834d37353ca5f40b460eae2aea73ef995bc710/docs/kola/external-tests.md#fast-build-and-iteration-on-your-projects-tests\n"
  },
  {
    "path": "docs/development/testing.md",
    "content": "---\nnav_order: 7\nparent: Development\n---\n\n# Testing\n\n## Unit Tests\nUnit tests can be run using `make check` (via `cargo test`).\n\n## External Kola Tests\n[External Kola tests][kola-ext-tests] can be found in the `tests/kola/` directory.\n\n### `server` Tests\nThe `tests/kola/server/` test directory contains tests that require access to a mock Cincinnati server. This test directory contains a Fedora CoreOS config that does the following:\n- creates a `/var/www/` directory\n- [sets up an HTTP server][kolet-httpd] at `localhost` listening on port `80` serving files from the `/var/www/` directory\n- configures Zincati to use `localhost` as its Cincinnati base URL\n- adds a systemd dropin to set Zincati's journal log verbosity to max (`-vvvv`)\n\nTests place mock release graphs in `/var/www/` for Zincati to fetch.\n\n### Running the Tests\nA built Fedora CoreOS image is required; it is recommended to use the CoreOS Assembler's [`build-fast` command][cosa-build-fast] for faster iteration.\n\nTo run the tests, specify the path to your Zincati project directory and which tests to run using `kola run`'s `-E` option.\n\nExample (run all tests):\n```\nkola run --qemu-image fastbuild-fedora-coreos-zincati-qemu.qcow2 -E /path/to/zincati/ 'ext.zincati.*'\n```\n\nExample (run only the `server` tests):\n```\nkola run --qemu-image fastbuild-fedora-coreos-zincati-qemu.qcow2 -E /path/to/zincati/ 'ext.zincati.server.*'\n```\n\n### Adding Tests\nRefer to kola external tests' [README][kola-ext-quick-start] for instructions on adding additional tests\n\n[kolet-httpd]: https://github.com/coreos/coreos-assembler/blob/main/docs/kola/external-tests.md#http-server\n[cosa-build-fast]: https://coreos.github.io/coreos-assembler/kola/external-tests/#fast-build-and-iteration-on-your-projects-tests\n[kola-ext-tests]: https://coreos.github.io/coreos-assembler/kola/external-tests/\n[kola-ext-quick-start]: https://coreos.github.io/coreos-assembler/kola/external-tests/#quick-start\n"
  },
  {
    "path": "docs/development/update-strategy-periodic.md",
    "content": "---\nnav_order: 6\nparent: Development\n---\n\n# Periodic update strategy\n\nThe agent supports a `periodic` strategy, which allows gating reboots based on \"reboot windows\", defined on weekly basis.\n\nThis strategy is a port of [locksmith reboot windows][locksmith], with a few differences:\n\n * multiple disjoint reboot windows are supported\n * multiple configuration entries are assembled into a single weekly calendar\n * weekdays need to be specified, in either long or abbreviated form\n * length duration is always specified in minutes\n\n[locksmith]: https://github.com/coreos/locksmith/tree/v0.6.2#reboot-windows\n\n# Timing and configuration\n\nWindow granularity is at the \"minutes\" level. For this reason, the configuration parameter `length_minutes` is a plain non-zero integer (instead of a free-form duration string).\n\nIn order to ease the case where the same time-window has to be applied on multiple specific days, the `days` parameter accepts a set of weekdays (instead of a single day).\n\nThe start of a reboot window is a single point in time, specified in 24h format with minutes granularity (e.g. `22:30`) via the `start_time` parameter.\n\nBy default, all times and dates are UTC-based.\nUTC times must be used to avoid:\n\n * shortening or skipping reboot windows due to Daylight Saving Time time-change\n * lengthening reboot windows due to Daylight Saving Time time-change\n * mixups due to short-notice law changes in time-zone definitions\n * errors due to stale `tzdata` entries\n * human confusion on machines with different local-timezone configurations\n\nOverall, the use of the default UTC times guarantee that the total weekly length for reboot windows is respected, regardless of local time zone laws.\n\nAs a side-effect, this also helps when cross-checking configurations across multiple machines located in different places.\n\nNevertheless, user-specified non-UTC time zones can still be configured, but with [caveats][time-zone-caveats].\n\n[time-zone-caveats]: ../usage/updates-strategy.md#time-zone-caveats\n\n# Implementation details\n\nConfiguration fragments are merged into a single weekly calendar.\n\nIn order to avoid too many unwieldy datetime operations to be performed in \"modulo 7 days\", all times are converted to \"minutes since beginning of week\".\nThis means that all datetimes are mapped to the range that goes from `0` (00:00 on Monday morning) to `MAX_WEEKLY_MINS` (23:59 on Sunday night).\nA reboot window which is specified across week boundary (e.g. starting on Sunday and ending on Monday) gets split into two sub-windows in order to respect the range above.\n\nReboot windows are internally stored within an [Augmented Interval Tree](https://en.wikipedia.org/wiki/Interval_tree#Augmented_tree) data-structure.\n"
  },
  {
    "path": "docs/development.md",
    "content": "---\nnav_order: 3\nhas_children: true\n---\n\n# Development\n"
  },
  {
    "path": "docs/images/zincati-actors.dot",
    "content": "# Render with: `dot -T png -o zincati-actors.png zincati-actors.dot`\n\ndigraph actors_messages {\n    newrank = true;\n    fontsize=11;\n    node [shape=box, style=\"rounded\", color=lightgrey; fontname=\"Arial\"; fontsize=11;];\n    edge[arrowhead=\"vee\"; fontcolor=darkgoldenrod; fontsize=8;];\n\n\n    subgraph cluster_metrics_service {\n        label = \"Async Actor:\\nmetrics service\";\n        style = dashed;\n        color = deepskyblue;\n\n        ConnectionStream [label=<StreamHandler<br/>&lt;<b>Connection</b>&gt;>;];\n        \n        # Invisble placeholders.\n        InvisMetricsClient:s [style=invis];\n        InvisBottomMetrics [style=invis];\n        ConnectionStream:s -> InvisBottomMetrics:n [style=invis];\n    }\n\n    subgraph cluster_dbus_server {\n        label = \"Sync Actor:\\nD-Bus server\";\n        style = dashed;\n        color = deepskyblue;\n\n        SyncLastRefersh [label=\"sync fn\\nlast_refresh_time()\"];\n\n        # Invisble placeholders.\n        InvisBottomDbus [style=invis];\n    }\n\n    subgraph cluster_update_agent {\n        label = \"Async Actor:\\nupdate agent\";\n        style = dashed;\n        color = deepskyblue;\n\n        AsyncLocalDeployments [label=\"async fn\\nlocal_deployments()\"];\n        AsyncAttemptDeploy [label=\"async fn\\nattempt_deploy()\"];\n        AsyncFinalizeDeployment [label=\"async fn\\nfinalize_deployment()\"];\n        RefreshTick [label=<Handler<br/>&lt;<b>RefreshTick</b>&gt;>];\n        LastRefresh [label=<Handler<br/>&lt;<b>LastRefresh</b>&gt;>]\n        \n    }\n    \n    subgraph cluster_rpm_ostree_client {\n        label = \"Sync Actor:\\nrpm-ostree client\";\n        style = dashed;\n        color = deepskyblue;\n\n        QueryLocalDeployments [label=<Handler<br/>&lt;<b>QueryLocalDeployments</b>&gt;>];\n        StageDeployment [label=<Handler<br/>&lt;<b>StageDeployment</b>&gt;>];\n        FinalizeDeployment [label=<Handler<br/>&lt;<b>FinalizeDeployment</b>&gt;>];\n       \n        # Invisble placeholders.\n        QueryLocalDeployments:s -> StageDeployment:n [style=invis];\n        StageDeployment:s -> FinalizeDeployment:n [style=invis];\n    }\n    \n    # Organize nodes in rows.\n    { rank = same; InvisMetricsClient; SyncLastRefersh; LastRefresh; AsyncLocalDeployments; QueryLocalDeployments }\n    { rank = same; ConnectionStream; RefreshTick; AsyncAttemptDeploy; StageDeployment }\n    { rank = same; InvisBottomMetrics; InvisBottomDbus; AsyncFinalizeDeployment; FinalizeDeployment; }\n\n    # Edges.\n    InvisMetricsClient:s -> ConnectionStream:n [label=\"Metrics\\nsocket\\n connection\"];\n    RefreshTick:ne -> RefreshTick:se;\n    { rank = same; SyncLastRefersh:ne -> LastRefresh:nw; LastRefresh:sw -> SyncLastRefersh:se; }\n    { rank = same; AsyncLocalDeployments:ne -> QueryLocalDeployments:nw; QueryLocalDeployments:sw -> AsyncLocalDeployments:se; }\n    { rank = same; AsyncAttemptDeploy:ne -> StageDeployment:nw; StageDeployment:sw -> AsyncAttemptDeploy:se; }\n    { rank = same; AsyncFinalizeDeployment:ne -> FinalizeDeployment:nw; FinalizeDeployment:sw -> AsyncFinalizeDeployment:se; }\n}\n"
  },
  {
    "path": "docs/images/zincati-fleetlock.msc",
    "content": "# Render with: `mscgen -T svg -i zincati-fleetlock.msc`\n\nmsc {\n  \"OS\", \"Zincati agent\", \"FleetLock service\", \"rpm-ostree daemon\";\n\n  ...;\n  |||;\n  \"Zincati agent\" rbox \"FleetLock service\" [label=\"Reboot slot locked\\n(possibly, from previous update)\\n\", textbgcolour=\"#cecece\"],\n  |||;\n\n  |||;\n  --- [label=\"System boot\"],\n  \"OS\" -> \"Zincati agent\" [label=\"Start zincati.service\", arcskip=1];\n  |||;\n  \"Zincati agent\" => \"FleetLock service\" [label=\"POST /v1/steady-state\", arcskip=1];\n  |||;\n  \"Zincati agent\" note \"FleetLock service\" [label=\"(Keep trying until steady-state is acknowledged...)\\n\"];\n  \"FleetLock service\" >> \"Zincati agent\" [label=\"OK\", arcskip=1];\n  |||;\n  \"Zincati agent\" rbox \"Zincati agent\" [label=\"Released any owned reboot slot\", textbgcolour=\"#46b8e3\"],\n  \"FleetLock service\" rbox \"FleetLock service\" [label=\"Locked reboot slots: 0\", textbgcolour=\"#ff7f7f\"],\n  |||;\n\n  |||;\n  ... [label=\"New update target found (TargetRevision)\"];\n  |||;\n\n  \"Zincati agent\" => \"rpm-ostree daemon\" [label=\"Deploy(TargetRevision)\", arcskip=1];\n  |||;\n  \"rpm-ostree daemon\" >> \"Zincati agent\" [label=\"OK\", arcskip=1];\n  |||;\n  \"Zincati agent\" => \"FleetLock service\" [label=\"POST /v1/pre-reboot\", arcskip=1];\n  |||;\n  \"Zincati agent\" note \"FleetLock service\" [label=\"(Keep trying until a reboot slot is available...)\\n\"];\n  \"FleetLock service\" >> \"Zincati agent\" [label=\"OK\", arcskip=1];\n  |||;\n  \"Zincati agent\" rbox \"Zincati agent\" [label=\"Owning a reboot slot\", textbgcolour=\"#46b8e3\"],\n  \"FleetLock service\" rbox \"FleetLock service\" [label=\"Locked reboot slots: 1\", textbgcolour=\"#ff7f7f\"],\n  |||;\n\n  |||;\n  \"Zincati agent\" => \"rpm-ostree daemon\" [label=\"Finalize(TargetRevision)\", arcskip=1];\n  |||;\n  \"rpm-ostree daemon\" >> \"Zincati agent\" [label=\"OK\", arcskip=1],\n  |||;\n  \"rpm-ostree daemon\" => \"OS\" [label=\"Reboot\", arcskip=2];\n  |||;\n  --- [label=\"System reboot\"];\n  |||;\n  \"Zincati agent\" rbox \"FleetLock service\" [label=\"Reboot slot locked\\n(for current update)\\n\", textbgcolour=\"#cecece\"];\n  ...;\n}\n"
  },
  {
    "path": "docs/images/zincati-fsm.dot",
    "content": "# Render with: `dot -T png -o zincati-fsm.png zincati-fsm.dot`\n# The `dot` program is included in Graphviz: https://graphviz.org/download/\n\ndigraph finite_state_machine {\n    rankdir=LR;\n    node [shape=circle, fontsize=10, fixedsize=true, width=1.1]; \n    edge [fontsize=10, fixedsize=true]; \n\n    node [label=\"StartState\"] StartState;\n    node [label=\"Initialized\"] Initialized;\n    node [label=\"ReportedSteady\"] ReportedSteady;\n    node [label=\"NoNewUpdate\"] NoNewUpdate;\n    node [label=\"UpdateAvailable\"] UpdateAvailable;\n    node [label=\"UpdateStaged\"] UpdateStaged;\n    node [label=\"UpdateFinalized\"] UpdateFinalized;\n    node [shape = doublecircle, label=\"EndState\"] EndState;\n\n    StartState -> Initialized [label=\"initialized()\"];\n    StartState -> EndState [label=\"end()\"];\n\n    Initialized -> ReportedSteady [label=\"reported_steady()\"];\n\n    ReportedSteady -> NoNewUpdate [label=\"no_new_update()\"];\n    ReportedSteady -> UpdateAvailable [label=\"update_available()\"];\n\n    NoNewUpdate -> NoNewUpdate [label=\"no_new_update()\"];\n    NoNewUpdate -> UpdateAvailable [label=\"update_available()\"];\n\n    UpdateAvailable -> UpdateAvailable [label=\"deploy_failed()\"];\n    UpdateAvailable -> NoNewUpdate [label=\"update_abandoned()\"];\n    UpdateAvailable -> UpdateStaged [label=\"update_staged()\"];\n\n    UpdateStaged -> UpdateFinalized [label=\"update_finalized()\"];\n    UpdateStaged -> UpdateStaged [label=\"reboot_postponed()\"];\n\n    UpdateFinalized -> EndState [label=\"end()\"];\n}\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nnav_order: 1\n---\n\n# Zincati\n\n[![crates.io](https://img.shields.io/crates/v/zincati.svg)](https://crates.io/crates/zincati)\n\nZincati is an auto-update agent for Fedora CoreOS hosts.\n\nIt works as a client for [Cincinnati] and [rpm-ostree], taking care of automatically updating/rebooting machines.\n\nFeatures:\n * Agent for [continuous auto-updates][auto-updates], with support for phased rollouts\n * [Configuration][configuration] via TOML dropins and overlaid directories\n * Multiple [update strategies][updates-strategy] for finalization/reboot\n * Local [maintenance windows][strategy-periodic] on a weekly schedule for planned upgrades\n * Internal [metrics][metrics] exposed over a local endpoint in Prometheus format\n * [Logging][logging] with configurable priority levels\n * Support for complex update-graphs via [Cincinnati protocol][cincinnati-protocol] (with rollout wariness, barriers, dead-ends and more)\n * Support for [cluster-wide reboot orchestration][strategy-fleetlock], via an external lock-manager\n\n![cluster reboot graph](images/metrics.png){:width=\"720px\"}\n\n[Cincinnati]: https://github.com/openshift/cincinnati\n[rpm-ostree]: https://github.com/coreos/rpm-ostree\n\n[auto-updates]: usage/auto-updates\n[configuration]: usage/configuration\n[updates-strategy]: usage/updates-strategy\n[strategy-periodic]: usage/updates-strategy#periodic-strategy\n[metrics]: usage/metrics\n[logging]: usage/logging\n[cincinnati-protocol]: development/cincinnati/protocol\n[strategy-fleetlock]: usage/updates-strategy#lock-based-strategy\n"
  },
  {
    "path": "docs/usage/agent-identity.md",
    "content": "---\nparent: Usage\n---\n\n# Agent identity\n\nZincati agent tries to derive a unique identity for the machine it is running on by introspecting the underlying OS and reading user configuration.\nThis includes assigning an ID and a group label specific to the agent, so that cluster-wide upgrades can be orchestrated via [phased rollouts][phased] and [lock-based][fleetlock-strategy] reboots.\n\n[phased]: auto-updates.md#phased-rollouts-client-wariness-canaries\n[fleetlock-strategy]:  updates-strategy.md#lock-based-strategy\n\n## Identity configuration\n\nAll agent identity values are normally auto-detected at startup and do not require user intervention.\n\nHowever, the following settings can be overridden through configuration fragments in the `identity` section:\n * `group`: group label, used for graph fetching ([Cincinnati][cincinnati]) and reboot orchestration ([FleetLock][fleetlock])\n * `node_uuid`: agent ID, used for graph fetching ([Cincinnati][cincinnati]) and reboot orchestration ([FleetLock][fleetlock])\n * `rollout_wariness`: agent wariness to [phased rollouts][phased], used for graph fetching ([Cincinnati][cincinnati]).\n\nThe following are defaults for each setting:\n- `group` (group label) is set to `default`\n- `node_uuid` (agent ID) is automatically generated, by hashing `/etc/machine-id` content\n- `rollout_wariness` is unset and the Cincinnati backend will assign a dynamic value to each request\n\nWhen the agent ID is not customized via configuration fragments, its default value is dynamically generated starting from `/etc/machine-id` content and from a Zincati specific application ID.\nFor more details about such application-specific machine IDs, see [machine-id][machine-id] documentation.\n\n[machine-id]: https://www.freedesktop.org/software/systemd/man/machine-id.html\n\n## Example\n\nAs an example, users can specify custom identity parameters by writing a configuration fragment to `/etc/lib/zincati/config.d/90-custom-identity.toml`:\n\n```toml\n[identity]\ngroup = \"workers\"\n```\n\nThe fragment above will steer the node into the \"workers\" reboot group.\n\n[cincinnati]: ../development/cincinnati/protocol.md\n[fleetlock]: ../development/fleetlock/protocol.md\n"
  },
  {
    "path": "docs/usage/auto-updates.md",
    "content": "---\nparent: Usage\n---\n\n# Auto-updates\n\nAvailable updates are discovered by periodically polling a [Cincinnati] server.\nOnce available, they are automatically applied via [rpm-ostree] and a machine reboot.\n\n[Cincinnati]: https://github.com/openshift/cincinnati\n[rpm-ostree]: https://github.com/projectatomic/rpm-ostree\n\n## Phased rollouts, client wariness, canaries\n\nOnce a new update payload is officially released, Zincati will eventually detect and apply the update automatically.\n\nHowever, there is no strict guarantee on the timing for an individual node to detect a new release, as the server will try to spread updates over a controlled timeframe.\n\nThis mechanism is called \"phased rollout\" and is meant to help release engineers and administrators in performing gradual updates and catching last-minute issues before they propagate to a large number of machines.\n\nPhased rollouts are orchestrated by the Cincinnati backend, by adjusting over time the percentage of clients to which an update is offered.\nClients do not usually need any additional setup to leverage phased rollouts.\nBy default, the Cincinnati backend dynamically assigns a specific rollout score to each client.\n\nHowever, clients can provide a \"rollout wariness\" hint to the server, in order to specify how eager they are to receive new updates.\n\nThe rollout wariness hint is configurable through the `rollout_wariness` parameter, as a floating point number going from `1.0` (very cautious) to `0.0` (very eager).\nFor example, a mildly cautious node can be configured using a configuration snippet like this:\n\n```toml\n[identity]\nrollout_wariness = 0.5\n```\n\nA common case is to have few dedicated nodes, also known as \"canaries\", that are configured to be very eager to receive updates, with a rollout wariness equal to `0.0`.\nThose nodes are meant to receive updates as soon as they are available, can afford some downtime, and are specifically monitored in order to detect issues before they start affecting a larger fleet of machines.\n\nIt is recommended to setup and monitor canary nodes, but otherwise normal worker nodes should not have zero wariness.\n\nThe default and recommended configuration does not set any static wariness value on Zincati side, leaving rollout decisions to Cincinnati backend.\n\n## Strategies for updates finalization\n\nZincati actively tries to detect and stage new updates whenever they become available.\nOnce a new payload has been locally staged, a machine reboot is required in order to atomically apply the update to the system as a whole.\n\nRebooting a machine does affect any workloads running on the machine at that time, and can potentially impact services across a whole cluster of nodes.\nFor such reason, Zincati allows the user to control when a node is allowed to reboot to finalize an auto-update.\n\nThe following finalization strategies are currently available:\n * immediately reboot to apply an update, as soon as it is downloaded and staged locally (`immediate` strategy, see [relevant documentation][strategy-immediate]).\n * use an external lock-manager to reboot a fleet of machines in a coordinated way (`fleet_lock` strategy, see [relevant documentation][strategy-fleet_lock]).\n * allow reboots only within locally configured maintenance windows, defined on a weekly basis (`periodic` strategy, see [relevant documentation][strategy-periodic]).\n\nBy default, the `immediate` strategy is used in order to proactively keep machines up-to-date.\n\nFor further documentation on configurations, check the [updates strategy][updates-strategy] documentation.\n\n[strategy-immediate]: updates-strategy.md#immediate-strategy\n[strategy-fleet_lock]: updates-strategy.md#lock-based-strategy\n[strategy-periodic]: updates-strategy.md#periodic-strategy\n[updates-strategy]: updates-strategy.md\n\n## Updates ordering and downgrades\n\nOS updates have a strict ascending ordering called \"age index\", which is based on the date and time of release.\nVersions that have been released earlier in time have a lower index than recent ones.\n\nZincati uses this absolute ordering to prefer newer releases (i.e. with higher age index) when multiple updates are available at the same time.\nBy default, this ordering is also used to prevent automatic downgrades.\n\nFor custom environments where automatic downgrades have to be supported, the following configuration snippet can be used to enable them:\n\n```toml\n[updates]\nallow_downgrade = true\n```\n\nEnabling such logic removes an additional safety check, and may allow rogue Cincinnati servers to induce downgrades to old releases with known security vulnerabilities.\nIt is generally not recommended to allow and perform automatic downgrades via Zincati.\n\n## Disabling auto-updates\n\nTo disable auto-updates, a configuration snippet containing the following has to be installed on the system:\n\n```toml\n[updates]\nenabled = false\n```\n\nMake sure that it has higher priority than previous settings, by using a path like `/etc/zincati/config.d/90-disable-auto-updates.toml`.\n\nWhen auto-updates are disabled, Zincati does not perform any update action.\nHowever, the service does not terminate and is kept alive idle for external status observers. \n"
  },
  {
    "path": "docs/usage/configuration.md",
    "content": "---\nparent: Usage\n---\n\n# Configuration\n\nZincati supports runtime customization via configuration fragments (dropins), allowing users and distributions to tweak the agent behavior by writing plain-text files.\n\nEach configuration fragment is a TOML snippet which is read by Zincati and assembled into the final runtime configuration. Only files with a `.toml` extension are considered.\n\nDropins are sourced from multiple directories, and merged by filename in lexicographic order.\n\nThe following configuration paths are scanned by Zincati, in order:\n * `/usr/lib/zincati/config.d/`: distribution defaults, read-only path owned by the OS.\n * `/etc/zincati/config.d/`: user customizations, writable path owned by the system administrator.\n * `/run/zincati/config.d/`: runtime customizations, writable path that is not persisted across reboots.\n\nConfiguration directives from files that appear later in sorting order can override prior directives.\n\nIf multiple files with the same name exist, only the last-sorting one is read.\n\nAdditionally, symbolic links to `/dev/null` can be used to completely override a prior file with the same name.\n\nConfiguration dropins are organized in multiple TOML sections, which are described in details in their own documentation pages.\n\n## Example\n\nAs an example, distribution defaults may generically enable a feature, but users may need to disable that in specific case.\n\nTo that extent, distributions can provide by default the following content at `/usr/lib/zincati/config.d/10-enable-feature.toml`:\n\n```toml\n[feature]\nenabled = true\n```\n\nIn order to override that setting, users can write the following to `/etc/zincati/config.d/90-disable-feature.toml`:\n\n```toml\n[feature]\nenabled = false\n```\n\nAfter sorting all configuration directives by directory and filename priority, the user-provided dropin is considered with the highest priority. Thus, it will override any conflicting directives from other fragments.\n"
  },
  {
    "path": "docs/usage/logging.md",
    "content": "---\nparent: Usage\n---\n\n# Logging\n\nZincati supports logging at multiple levels (trace, debug, info, warning, error). Usually only log messages at or above warning level are emitted.\nLog verbosity can be increased by passing multiple `-v` flags as command-line arguments.\n\n## Tweaking agent verbosity\n\nBy default, the Zincati agent is started with info level logging enabled (i.e. `-v`). However, logging verbosity can be freely tweaked via systemd drop-in files.\n\nFor example, debug logging (`-vv`) can be enabled by creating a drop-in file at `/etc/systemd/system/zincati.service.d/10-verbosity.conf` with the following contents:\n\n```\n[Service]\nEnvironment=ZINCATI_VERBOSITY=\"-vv\"\n```\n\nThe maximum level (`-vvv`) equates to trace and can be very verbose. It is only meant for development/debugging and for short timespans.\nIt is recommended to not use the trace log level in production or for long periods of time as it reduces the signal-to-noise ratio and can easily saturate further log-persisting systems.\n\n## Inspecting logs\n\nBy default Zincati runs as a systemd service, and its log messages are captured by systemd-journald.\n\nMost recent logs can be inspected via `sudo journalctl -b 0 -e -u zincati.service`. The resulting output may look like this:\n\n```\n-- Logs begin at Sat 2020-09-12 16:12:13 UTC, end at Wed 2020-09-30 12:52:05 UTC. --\nSep 23 10:48:27 localhost systemd[1]: Started Zincati Update Agent.\nSep 23 10:48:27 localhost zincati[678]: [INFO ] starting update agent (zincati 0.0.12)\nSep 23 10:48:34 localhost zincati[678]: [INFO ] Cincinnati service: https://updates.coreos.fedoraproject.org\nSep 23 10:48:34 localhost zincati[678]: [INFO ] agent running on node '<ID>', in update group '<GROUP>'\nSep 23 10:48:34 localhost zincati[678]: [INFO ] initialization complete, auto-updates logic enabled\n...\n```\n\nOptionally, `journalctl` allows to follow log messages emitted in real time by additionally passing a `-f` flag.\n"
  },
  {
    "path": "docs/usage/metrics.md",
    "content": "---\nparent: Usage\n---\n\n# Metrics\n\nZincati tracks and exposes some of its internal metrics, in order to ease monitoring tasks across a large fleet of nodes.\n\nMetrics are collected and exported according to [Prometheus][Prometheus] [textual format][prom-text], over a local endpoint.\n\n[Prometheus]: https://prometheus.io/\n[prom-text]: https://prometheus.io/docs/instrumenting/exposition_formats/\n\n## Gathering metrics\n\nTo gather metrics from a locally running Zincati instance, it is sufficient to connect and read from the Unix-domain socket located at `/run/zincati/public/metrics.promsock`.\n\nFor example, manual inspection can be performed via `socat`:\n\n```\n$ sudo socat - UNIX-CONNECT:/run/zincati/public/metrics.promsock\n\n# HELP zincati_update_agent_last_refresh_timestamp UTC timestamp of update-agent last refresh tick.\n# TYPE zincati_update_agent_last_refresh_timestamp gauge\nzincati_update_agent_last_refresh_timestamp 1563360122\n# HELP zincati_update_agent_latest_state_change_timestamp UTC timestamp of update-agent last state change.\n# TYPE zincati_update_agent_latest_state_change_timestamp gauge\nzincati_update_agent_latest_state_change_timestamp 1563360122\n# HELP zincati_update_agent_updates_enabled Whether auto-updates logic is enabled.\n# TYPE zincati_update_agent_updates_enabled gauge\nzincati_update_agent_updates_enabled 1\n[...]\n```\n\nAdditionally, the local Unix-domain socket can be proxied to HTTP and exposed to Prometheus.\nFor an example of such setup, check the [local\\_exporter][local_exporter] repository.\n\n[local_exporter]: https://github.com/lucab/local_exporter\n"
  },
  {
    "path": "docs/usage/updates-strategy.md",
    "content": "---\nparent: Usage\n---\n\n# Updates strategy\n\nTo minimize service disruption, Zincati allows administrators to control when machines are allowed to reboot and finalize auto-updates.\n\nSeveral updates strategies are supported, which can be configured at runtime via configuration snippets as shown below.\nIf not otherwise configured, the default updates strategy resolves to `immediate`.\n\n# Immediate strategy\n\nThe simplest updates strategy consists of minimal logic to immediately finalize an update as soon as it is staged locally.\n\nFor configuration purposes, such strategy is labeled `immediate` and takes no additional configuration parameters.\n\nThis strategy can be enabled via a configuration snippet like the following:\n\n```toml\n[updates]\nstrategy = \"immediate\"\n```\n\nThe `immediate` strategy is an aggressive finalization method which is biased towards finalizing updates as soon as possible, and it is only aware of node-local state.\n\nSuch an approach is only recommended for environments where temporary service interruption are not problematic, or there is no need for more complex reboot scheduling.\n\n# Lock-based strategy\n\nIn case of a fleet of machines grouped into a cluster, it is often required to orchestrate reboots so that hosted services are not disrupted when single nodes are rebooting to finalize updates.\nIn this case it is helpful to have an external orchestrator managing reboots cluster-wide, and having each machine trying to lock (and unlock) a reboot slot with the centralized lock-manager.\n\nSeveral distributed databases and lock-managers exist for such purpose, each one with a specific remote API for clients and a variety of transport mechanisms.\nZincati does not mandate any specific lock-manager or database, but instead it uses a simple HTTP-based protocol modeling a distributed counting semaphore with recursive locking, called [FleetLock][fleet_lock], \n\nIn short, it consists of two operations:\n * lock: before rebooting, a reboot slot must be locked (and confirmed) by the lock-manager.\n * unlock: after rebooting, any reboot slot owned by the node must be unlocked (and confirmed) by the lock-manager before proceeding further.\n\nThis protocol is not coupled to any specific backend, and can be implemented on top of any suitable infrastructure:\n * [airlock] is a free-software project which implements such protocol on top of [etcd3].\n * a Kubernetes-based reboot-manager is provided as part of [Typhoon](https://github.com/poseidon/fleetlock).\n * <https://github.com/opencounter/terraform-fleet-lock-dynamodb> is a serverless implementation via AWS API Gateway and DynamoDB.\n\nFor configuration purposes, such strategy is labeled `fleet_lock` and takes the following configuration parameters:\n * `base_url` (string, mandatory, non-empty): the base URL for the FleetLock service.\n\nThis strategy can be enabled via a configuration snippet like the following:\n\n```toml\n[updates]\nstrategy = \"fleet_lock\"\n\n[updates.fleet_lock]\nbase_url = \"http://example.com/fleet_lock/\"\n```\n\nThe `fleet_lock` strategy is a conservative method which is biased towards avoiding service disruptions, but it requires an external component which is aware of cluster-wide state.\n\nSuch an approach is only recommended where nodes are already grouped into an orchestrated cluster, which can thus provide better overall scheduling decisions.\n\n[fleet_lock]: ../development/fleetlock/protocol.md\n[airlock]: https://github.com/coreos/airlock\n[etcd3]: https://etcd.io/\n\n# Periodic strategy\n\nThe `periodic` strategy allows Zincati to only reboot for updates during certain timeframes, also known as \"maintenance windows\" or \"reboot windows\".\nOutside of those maintenance windows, reboots are not automatically performed and auto-updates are staged and held until the next available window.\n\nReboot windows recur on a weekly basis, and can be defined in any arbitrary order and length. Their individual length must be greater than zero.\nBy default, all maintenance windows are defined in UTC dates and times. This is meant to avoid timezone-related skews in a fleet of machines, as well as possible side-effects of Daylight Savings Time (DST) policies.\n\nPeriodic reboot windows can be configured and enabled in the following way:\n\n```toml\n[updates]\nstrategy = \"periodic\"\n\n[[updates.periodic.window]]\ndays = [ \"Sat\", \"Sun\" ]\nstart_time = \"23:30\"\nlength_minutes = 60\n\n[[updates.periodic.window]]\ndays = [ \"Wed\" ]\nstart_time = \"01:00\"\nlength_minutes = 30\n```\n\nThe above configuration would result in three maintenance windows during which Zincati is allowed to reboot the machine for updates:\n * 60 minutes starting at 23:30 UTC on Saturday night, and ending at 00:30 UTC on Sunday morning\n * 60 minutes starting at 23:30 UTC on Sunday night, and ending at 00:30 UTC on Monday morning\n * 30 minutes starting at 01:00 UTC on Wednesday morning, and ending at 01:30 UTC on Wednesday morning\n\nReboot windows can be separately configured in multiple snippets, as long as each `updates.periodic.window` entry contains all the required properties:\n * `days`: an array of weekdays (C locale), either in full or abbreviated (first three letters) form\n * `start_time`: window starting time, in `hh:mm` ISO 8601 format\n * `length_minutes`: non-zero window duration, in minutes\n\nFor convenience, multiple entries can be defined with overlapping times, and each window definition is allowed to cross day and week boundaries (wrapping to the next day).\n\n## Time zone configuration\n\nTo configure a non-UTC time zone for all the reboot windows, specify the `time_zone` field in a `updates.periodic` entry. The specified time zone must be either `\"localtime\"` or a time zone name from the [IANA Time Zone Database][IANA_tz_db] (you can find an unofficial list of time zone names [here][wikipedia_tz_names]).\n\nIf using `\"localtime\"`, the system's [local time zone configuration file][localtime], `/etc/localtime`, is used. As such, `/etc/localtime` must either be a symlink to a valid `tzfile` entry in your system's local time zone database (under `/usr/share/zoneinfo/`), or not exist, in which case `UTC` is used.\n\nNote that you can only specify a single time zone for _all_ reboot windows.\n\nA time zone can be specified in the following way:\n\n```toml\n[updates]\nstrategy = \"periodic\"\n\n[updates.periodic]\ntime_zone = \"America/Panama\"\n\n[[updates.periodic.window]]\ndays = [ \"Sat\", \"Sun\" ]\nstart_time = \"23:30\"\nlength_minutes = 60\n\n[[updates.periodic.window]]\ndays = [ \"Mon\" ]\nstart_time = \"00:00\"\nlength_minutes = 60\n```\n\nSince Panama does not have Daylight Savings Time and follows Eastern Standard Time (which has a fixed offset of UTC -5) all year, the above configuration would result in two maintenance windows during which Zincati is allowed to reboot the machine for updates:\n * 60 minutes starting at 23:30 EST on Saturday night, and ending at 00:30 EST on Sunday morning\n * 90 minutes starting at 23:30 EST on Sunday night, and ending at 01:00 EST on Monday morning\n\n### Time zone caveats\n\n⚠️ **Reboot window lengths may vary.** ⚠️\n\nBecause reboot window clock times are always obeyed, reboot windows may be lengthened or shortened due to shifts in clock time. For example, with the `US/Eastern` time zone which shifts between Eastern Standard Time and Eastern Daylight Time, on \"fall back\" day, a specified reboot window may be lengthened by up to one hour; on \"spring forward\" day, a specified reboot window may be shortened by up to one hour, or skipped entirely.\n\nExample of varying length reboot windows using the `US/Eastern` time zone:\n\n```toml\n[updates]\nstrategy = \"periodic\"\n\n[updates.periodic]\ntime_zone = \"US/Eastern\"\n\n[[updates.periodic.window]]\ndays = [ \"Sun\" ]\nstart_time = \"01:30\"\nlength_minutes = 60\n```\n\nThe above configuration will result in reboots being allowed at 1:30 AM to 2:30 AM on _every_ Sunday. This includes days when a Daylight Savings Shift occurs.\n\nOn the `US/Eastern` time zone's \"fall back\" day, where clocks are shifted back by one hour on a Sunday in Fall just before 3:00 AM, the thirty minutes between 2:00 AM and 2:30 AM will occur twice. As such, the reboot window will be lengthened by thirty minutes each year on \"fall back\" day.\n\nOn \"spring forward\" day, where clocks are shifted forward by one hour on a Sunday in Spring just before 2:00 AM, the thirty minutes between 2:00 AM and 2:30 AM will not occur. As such, the reboot window will be shortened by thirty minutes each year on \"spring forward\" day. Effectively, the reboot window on \"spring forward\" day will only be between 1:30 AM and 2:00 AM.\n\n⚠️ **Incorrect reboot times due to stale time zone database.** ⚠️\n\nTime zone data is read from the system's time zone database at `/usr/share/zoneinfo`. This directory and its contents are part of the `tzdata` RPM package; in the latest release of Fedora CoreOS, `tzdata` should be kept fairly up-to-date with the latest official release from the IANA.\nHowever, if your system does not have the latest IANA time zone database, or there is a sudden policy change in the jurisdiction associated with your configured time zone, then reboots may happen at unexpected and incorrect times.\n\n[IANA_tz_db]: https://www.iana.org/time-zones\n[wikipedia_tz_names]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\n[localtime]: https://www.freedesktop.org/software/systemd/man/localtime.html\n"
  },
  {
    "path": "docs/usage.md",
    "content": "---\nnav_order: 2\nhas_children: true\n---\n\n# Usage\n"
  },
  {
    "path": "src/cincinnati/client.rs",
    "content": "//! Asynchronous Cincinnati client.\n//!\n//! This client implements the [Cincinnati protocol] for update-hints.\n//!\n//! [Cincinnati protocol]: https://github.com/openshift/cincinnati/blob/master/docs/design/cincinnati.md#graph-api\n\n// TODO(lucab): eventually move to its own \"cincinnati client library\" crate\n\nuse anyhow::{Context, Result};\nuse futures::prelude::*;\nuse reqwest::Method;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::time::Duration;\nuse thiserror::Error;\n\n/// Default timeout for HTTP requests completion (30 minutes).\nconst DEFAULT_HTTP_COMPLETION_TIMEOUT: Duration = Duration::from_secs(30 * 60);\n\n/// Cincinnati graph API path endpoint (v1).\nstatic V1_GRAPH_PATH: &str = \"v1/graph\";\n\n/// Cincinnati JSON protocol: node object.\n#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]\npub struct Node {\n    pub version: String,\n    pub payload: String,\n    pub metadata: HashMap<String, String>,\n}\n\n/// Cincinnati JSON protocol: graph object.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub struct Graph {\n    pub nodes: Vec<Node>,\n    pub edges: Vec<(u64, u64)>,\n}\n\n/// Cincinnati JSON protocol: service error.\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]\npub struct GraphJsonError {\n    /// Machine-friendly brief error kind.\n    pub(crate) kind: String,\n    /// Human-friendly detailed error explanation.\n    pub(crate) value: String,\n}\n\n/// Error related to the Cincinnati service.\n#[derive(Clone, Debug, Error, PartialEq, Eq)]\npub enum CincinnatiError {\n    /// Graph endpoint error.\n    Graph(reqwest::StatusCode, GraphJsonError),\n    /// Generic HTTP error.\n    Http(reqwest::StatusCode),\n    /// Client builder failed.\n    FailedClientBuilder(String),\n    /// Client failed JSON decoding.\n    FailedJsonDecoding(String),\n    /// Failed to lookup node in graph.\n    FailedNodeLookup(String),\n    /// Failed parsing node from graph.\n    FailedNodeParsing(String),\n    /// Client failed request.\n    FailedRequest(String),\n}\n\nimpl CincinnatiError {\n    /// Return the machine-friendly brief error kind.\n    pub fn error_kind(&self) -> String {\n        match *self {\n            CincinnatiError::Graph(_, ref err) => err.kind.clone(),\n            CincinnatiError::Http(status) => format!(\"generic_http_{}\", status.as_u16()),\n            CincinnatiError::FailedClientBuilder(_) => \"client_failed_build\".to_string(),\n            CincinnatiError::FailedJsonDecoding(_) => \"client_failed_json_decoding\".to_string(),\n            CincinnatiError::FailedNodeLookup(_) => \"client_failed_node_lookup\".to_string(),\n            CincinnatiError::FailedNodeParsing(_) => \"client_failed_node_parsing\".to_string(),\n            CincinnatiError::FailedRequest(_) => \"client_failed_request\".to_string(),\n        }\n    }\n\n    /// Return the human-friendly detailed error explanation.\n    pub fn error_value(&self) -> String {\n        match *self {\n            CincinnatiError::Graph(_, ref err) => err.value.clone(),\n            CincinnatiError::Http(_) => \"(unknown/generic server error)\".to_string(),\n            CincinnatiError::FailedClientBuilder(ref err)\n            | CincinnatiError::FailedJsonDecoding(ref err)\n            | CincinnatiError::FailedNodeLookup(ref err)\n            | CincinnatiError::FailedNodeParsing(ref err)\n            | CincinnatiError::FailedRequest(ref err) => err.clone(),\n        }\n    }\n\n    /// Return the server-side error status code, if any.\n    pub fn status_code(&self) -> Option<u16> {\n        match *self {\n            CincinnatiError::Graph(s, _) | CincinnatiError::Http(s) => Some(s.as_u16()),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for CincinnatiError {\n    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n        // Account for both server-side and client-side failures.\n        let context = match self.status_code() {\n            Some(s) => format!(\"server-side error, code {}\", s),\n            None => \"client-side error\".to_string(),\n        };\n        write!(f, \"{}: {}\", context, self.error_value())\n    }\n}\n\n/// Client to make outgoing API requests.\n#[derive(Clone, Debug)]\npub struct Client {\n    /// Base URL for API endpoint.\n    api_base: reqwest::Url,\n    /// Asynchronous reqwest client.\n    hclient: reqwest::Client,\n    /// Client parameters (query portion).\n    query_params: HashMap<String, String>,\n}\n\nimpl Client {\n    /// Fetch an update-graph from Cincinnati.\n    pub fn fetch_graph(&self) -> impl Future<Output = Result<Graph, CincinnatiError>> {\n        let req = self\n            .new_request(Method::GET, V1_GRAPH_PATH)\n            .map_err(|e| CincinnatiError::FailedRequest(e.to_string()));\n\n        futures::future::ready(req)\n            .and_then(|req| {\n                req.send()\n                    .map_err(|e| CincinnatiError::FailedRequest(e.to_string()))\n            })\n            .and_then(Self::map_response)\n    }\n\n    /// Return a request builder with base URL and parameters set.\n    fn new_request<S: AsRef<str>>(\n        &self,\n        method: reqwest::Method,\n        url_suffix: S,\n    ) -> Result<reqwest::RequestBuilder> {\n        let url = self.api_base.clone().join(url_suffix.as_ref())?;\n        let builder = self\n            .hclient\n            .request(method, url)\n            .header(\"accept\", \"application/json\")\n            .query(&self.query_params);\n        Ok(builder)\n    }\n\n    /// Map an HTTP response to a service result.\n    async fn map_response(response: reqwest::Response) -> Result<Graph, CincinnatiError> {\n        let status = response.status();\n\n        // On success, try to decode graph.\n        if status.is_success() {\n            let graph = response.json::<Graph>().await.map_err(|e| {\n                CincinnatiError::FailedJsonDecoding(format!(\"failed to decode graph: {}\", e))\n            })?;\n            return Ok(graph);\n        }\n\n        // On error, decode failure details (or synthesize a generic error).\n        match response.json::<GraphJsonError>().await {\n            Ok(rej) => Err(CincinnatiError::Graph(status, rej)),\n            _ => Err(CincinnatiError::Http(status)),\n        }\n    }\n}\n\n/// Client builder.\n#[derive(Clone, Debug)]\npub struct ClientBuilder {\n    /// Base URL for API endpoint (mandatory).\n    api_base: String,\n    /// Asynchronous reqwest client (custom).\n    hclient: Option<reqwest::Client>,\n    /// Client parameters (custom).\n    query_params: Option<HashMap<String, String>>,\n}\n\nimpl ClientBuilder {\n    /// Return a new builder for the given base API endpoint URL.\n    pub fn new<T>(api_base: T) -> Self\n    where\n        T: Into<String>,\n    {\n        Self {\n            api_base: api_base.into(),\n            hclient: None,\n            query_params: None,\n        }\n    }\n\n    /// Set (or reset) the query parameters to use.\n    pub fn query_params(self, params: Option<HashMap<String, String>>) -> Self {\n        let mut builder = self;\n        builder.query_params = params;\n        builder\n    }\n\n    /// Build a client with specified parameters.\n    pub fn build(self) -> Result<Client> {\n        let hclient = match self.hclient {\n            Some(client) => client,\n            None => reqwest::ClientBuilder::new()\n                .timeout(DEFAULT_HTTP_COMPLETION_TIMEOUT)\n                .build()?,\n        };\n        let query_params = self.query_params.unwrap_or_default();\n\n        let api_base = reqwest::Url::parse(&self.api_base)\n            .context(format!(\"failed to parse '{}'\", &self.api_base))?;\n        let client = Client {\n            api_base,\n            hclient,\n            query_params,\n        };\n        Ok(client)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use http::response::Response;\n    use http::status::StatusCode;\n    use tokio::runtime as rt;\n\n    #[test]\n    fn test_graph_server_error_display() {\n        let err_body = r#\"\n{\n  \"kind\": \"failure_foo\",\n  \"value\": \"failed to perform foo\"\n}\n\"#;\n        let runtime = rt::Runtime::new().unwrap();\n        let response = Response::builder().status(466).body(err_body).unwrap();\n        let fut_rejection = Client::map_response(response.into());\n        let rejection = runtime.block_on(fut_rejection).unwrap_err();\n        let expected_rejection = CincinnatiError::Graph(\n            StatusCode::from_u16(466).unwrap(),\n            GraphJsonError {\n                kind: \"failure_foo\".to_string(),\n                value: \"failed to perform foo\".to_string(),\n            },\n        );\n        assert_eq!(&rejection, &expected_rejection);\n\n        let msg = rejection.to_string();\n        let expected_msg = \"server-side error, code 466: failed to perform foo\";\n        assert_eq!(&msg, expected_msg);\n    }\n\n    #[test]\n    fn test_graph_http_error_display() {\n        let runtime = rt::Runtime::new().unwrap();\n        let response = Response::builder().status(433).body(\"\").unwrap();\n        let fut_rejection = Client::map_response(response.into());\n        let rejection = runtime.block_on(fut_rejection).unwrap_err();\n        let expected_rejection = CincinnatiError::Http(StatusCode::from_u16(433).unwrap());\n        assert_eq!(&rejection, &expected_rejection);\n\n        let msg = rejection.to_string();\n        let expected_msg = \"server-side error, code 433: (unknown/generic server error)\";\n        assert_eq!(&msg, expected_msg);\n    }\n\n    #[test]\n    fn test_graph_client_error_display() {\n        let runtime = rt::Runtime::new().unwrap();\n        let response = Response::builder().status(200).body(\"{}\").unwrap();\n        let fut_rejection = Client::map_response(response.into());\n        let rejection = runtime.block_on(fut_rejection).unwrap_err();\n        let expected_rejection = CincinnatiError::FailedJsonDecoding(\n            \"failed to decode graph: error decoding response body\".to_string(),\n        );\n        assert_eq!(&rejection, &expected_rejection);\n\n        let msg = rejection.to_string();\n        let expected_msg =\n            \"client-side error: failed to decode graph: error decoding response body\";\n        assert_eq!(&msg, expected_msg);\n    }\n}\n"
  },
  {
    "path": "src/cincinnati/mock_tests.rs",
    "content": "use crate::cincinnati::*;\nuse crate::identity::Identity;\nuse mockito::{self, Matcher};\nuse std::collections::BTreeSet;\nuse tokio::runtime as rt;\n\n#[test]\nfn test_empty_graph() {\n    let mut server = mockito::Server::new();\n    let empty_graph = r#\"{ \"nodes\": [], \"edges\": [] }\"#;\n    let m_graph = server\n        .mock(\"GET\", Matcher::Regex(r\"^/v1/graph?.+$\".to_string()))\n        .with_status(200)\n        .with_header(\"accept\", \"application/json\")\n        .with_body(empty_graph)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = Cincinnati {\n        base_url: server.url(),\n    };\n    let update = runtime.block_on(client.next_update(&id, BTreeSet::new(), false));\n    m_graph.assert();\n\n    assert!(update.unwrap().is_none());\n}\n"
  },
  {
    "path": "src/cincinnati/mod.rs",
    "content": "//! Asynchronous Cincinnati client.\n\n// Cincinnati client.\nmod client;\npub use client::{CincinnatiError, Node};\n\n#[cfg(test)]\nmod mock_tests;\n\nuse crate::config::inputs;\nuse crate::identity::Identity;\nuse crate::rpm_ostree::{Payload, Release};\nuse anyhow::{Context, Result};\nuse fn_error_context::context;\nuse futures::prelude::*;\nuse futures::TryFutureExt;\nuse prometheus::{IntCounter, IntCounterVec, IntGauge};\nuse serde::Serialize;\nuse std::collections::BTreeSet;\nuse std::pin::Pin;\nuse std::sync::atomic::{AtomicU8, Ordering};\n\n/// Metadata key for payload scheme.\npub static AGE_INDEX_KEY: &str = \"org.fedoraproject.coreos.releases.age_index\";\n\n/// Metadata key for payload scheme.\npub static SCHEME_KEY: &str = \"org.fedoraproject.coreos.scheme\";\n\n/// Metadata key for dead-end sentinel.\npub static DEADEND_KEY: &str = \"org.fedoraproject.coreos.updates.deadend\";\n\n/// Metadata key for dead-end reason.\npub static DEADEND_REASON_KEY: &str = \"org.fedoraproject.coreos.updates.deadend_reason\";\n\n/// Metadata value for \"oci\" payload scheme.\npub const OCI_SCHEME: &str = \"oci\";\n\nlazy_static::lazy_static! {\n    static ref GRAPH_NODES: IntGauge = register_int_gauge!(opts!(\n        \"zincati_cincinnati_graph_nodes_count\",\n        \"Number of nodes in Cincinnati update graph.\"\n    )).unwrap();\n    static ref GRAPH_EDGES: IntGauge = register_int_gauge!(opts!(\n        \"zincati_cincinnati_graph_edges_count\",\n        \"Number of edges in Cincinnati update graph.\"\n    )).unwrap();\n    static ref BOOTED_DEADEND: IntGauge = register_int_gauge!(\n        \"zincati_cincinnati_booted_release_is_deadend\",\n        \"Whether currently booted OS release is a dead-end.\"\n    ).unwrap();\n    static ref UPDATE_TARGETS_IGNORED: IntGauge = register_int_gauge!(\n        \"zincati_cincinnati_ignored_update_targets\",\n        \"Number of ignored targets among update targets found.\"\n    ).unwrap();\n    static ref UPDATE_CHECKS: IntCounter = register_int_counter!(opts!(\n        \"zincati_cincinnati_update_checks_total\",\n        \"Total number of checks for updates to the upstream Cincinnati server.\"\n    )).unwrap();\n    static ref UPDATE_CHECKS_ERRORS: IntCounterVec = register_int_counter_vec!(\n        \"zincati_cincinnati_update_checks_errors_total\",\n        \"Total number of errors while checking for updates.\",\n        &[\"kind\"]\n    ).unwrap();\n    static ref DEADEND_STATE : DeadEndState = DeadEndState::default();\n}\n\n/// For tracking a dead-end release.\npub struct DeadEndState(AtomicU8);\n\nimpl Default for DeadEndState {\n    fn default() -> Self {\n        Self(AtomicU8::new(DeadEndState::UNKNOWN))\n    }\n}\n\nimpl DeadEndState {\n    const FALSE: u8 = 0;\n    const TRUE: u8 = 1;\n    const UNKNOWN: u8 = 2;\n\n    /// Return whether this is in a known dead-end state.\n    pub fn is_deadend(&self) -> bool {\n        self.0.load(Ordering::SeqCst) == Self::TRUE\n    }\n\n    /// Return whether this is in a known NOT dead-end state.\n    pub fn is_no_deadend(&self) -> bool {\n        self.0.load(Ordering::SeqCst) == Self::FALSE\n    }\n\n    pub fn set_deadend(&self) {\n        self.0.store(Self::TRUE, Ordering::SeqCst);\n    }\n\n    pub fn set_no_deadend(&self) {\n        self.0.store(Self::FALSE, Ordering::SeqCst);\n    }\n}\n\n/// Cincinnati configuration.\n#[derive(Debug, Serialize, Clone)]\npub struct Cincinnati {\n    /// Service base URL.\n    pub base_url: String,\n}\n\nimpl Cincinnati {\n    /// Process Cincinnati configuration.\n    #[context(\"failed to validate cincinnati configuration\")]\n    pub(crate) fn with_config(cfg: inputs::CincinnatiInput, id: &Identity) -> Result<Self> {\n        if cfg.base_url.is_empty() {\n            anyhow::bail!(\"empty Cincinnati base URL\");\n        }\n\n        // Substitute templated key with agent runtime values.\n        let base_url = if envsubst::is_templated(&cfg.base_url) {\n            let context = id.url_variables();\n            envsubst::validate_vars(&context)?;\n            envsubst::substitute(cfg.base_url, &context)?\n        } else {\n            cfg.base_url\n        };\n        log::info!(\"Cincinnati service: {}\", &base_url);\n\n        let c = Self { base_url };\n        Ok(c)\n    }\n\n    /// Fetch next update-hint from Cincinnati.\n    pub(crate) fn fetch_update_hint(\n        &self,\n        id: &Identity,\n        denylisted_depls: BTreeSet<Release>,\n        allow_downgrade: bool,\n    ) -> Pin<Box<dyn Future<Output = Option<Release>>>> {\n        UPDATE_CHECKS.inc();\n        log::trace!(\"checking upstream Cincinnati server for updates\");\n\n        let update = self\n            .next_update(id, denylisted_depls, allow_downgrade)\n            .unwrap_or_else(|e| {\n                UPDATE_CHECKS_ERRORS\n                    .with_label_values(&[&e.error_kind()])\n                    .inc();\n                log::error!(\"failed to check Cincinnati for updates: {}\", e);\n                None\n            });\n        Box::pin(update)\n    }\n\n    /// Get the next update.\n    fn next_update(\n        &self,\n        id: &Identity,\n        denylisted_depls: BTreeSet<Release>,\n        allow_downgrade: bool,\n    ) -> Pin<Box<dyn Future<Output = Result<Option<Release>, CincinnatiError>>>> {\n        let booted = id.current_os.clone();\n        let params = id.cincinnati_params();\n        let client = client::ClientBuilder::new(self.base_url.to_string())\n            .query_params(Some(params))\n            .build()\n            .map_err(|e| CincinnatiError::FailedClientBuilder(e.to_string()));\n\n        let next = futures::future::ready(client)\n            .and_then(|c| c.fetch_graph())\n            .and_then(move |graph| async move {\n                find_update(graph, booted, denylisted_depls, allow_downgrade)\n            });\n        Box::pin(next)\n    }\n}\n\n/// Evaluate and record whether booted OS is a dead-end release, and\n/// log that information in a MOTD file.\nfn refresh_deadend_status(node: &Node) -> Result<()> {\n    match evaluate_deadend(node) {\n        Some(reason) => {\n            BOOTED_DEADEND.set(1);\n            if !DEADEND_STATE.is_deadend() {\n                log::warn!(\"current release detected as dead-end, reason: {}\", reason);\n                std::process::Command::new(\"pkexec\")\n                    .arg(\"/usr/libexec/zincati\")\n                    .arg(\"deadend-motd\")\n                    .arg(\"set\")\n                    .arg(\"--reason\")\n                    .arg(reason)\n                    .output()\n                    .context(\"failed to write dead-end release information\")?;\n                DEADEND_STATE.set_deadend();\n                log::debug!(\"MOTD updated with dead-end state\");\n            }\n        }\n        None => {\n            BOOTED_DEADEND.set(0);\n            if !DEADEND_STATE.is_no_deadend() {\n                log::info!(\"current release detected as not a dead-end\");\n                std::process::Command::new(\"pkexec\")\n                    .arg(\"/usr/libexec/zincati\")\n                    .arg(\"deadend-motd\")\n                    .arg(\"unset\")\n                    .output()\n                    .context(\"failed to remove dead-end release MOTD file\")?;\n                DEADEND_STATE.set_no_deadend();\n                log::debug!(\"MOTD updated with no dead-end state\");\n            }\n        }\n    };\n    Ok(())\n}\n\n/// Walk the graph, looking for an update reachable from the given digest.\nfn find_update(\n    graph: client::Graph,\n    booted_depl: Release,\n    denylisted_depls: BTreeSet<Release>,\n    allow_downgrade: bool,\n) -> Result<Option<Release>, CincinnatiError> {\n    GRAPH_NODES.set(graph.nodes.len() as i64);\n    GRAPH_EDGES.set(graph.edges.len() as i64);\n    log::trace!(\n        \"got an update graph with {} nodes and {} edges\",\n        graph.nodes.len(),\n        graph.edges.len()\n    );\n\n    // Find booted deployment in graph.\n    let (cur_position, cur_node) = match graph\n        .nodes\n        .iter()\n        .enumerate()\n        .find(|(_, node)| is_same_checksum(node, &booted_depl))\n    {\n        Some(current) => current,\n        None => {\n            log::warn!(\n                \"booted deployment {} not found in the update graph\",\n                &booted_depl.payload\n            );\n            return Ok(None);\n        }\n    };\n    drop(booted_depl);\n    let cur_release = Release::from_cincinnati(cur_node.clone())\n        .map_err(|e| CincinnatiError::FailedNodeParsing(e.to_string()))?;\n\n    if let Err(e) = refresh_deadend_status(cur_node) {\n        log::warn!(\"failed to refresh dead-end status: {}\", e);\n    }\n    // Evaluate and record whether booted OS is a dead-end release.\n    // TODO(lucab): consider exposing this information in more places\n    // (e.g. logs, motd, env/json file in a well-known location).\n    let is_deadend: i64 = evaluate_deadend(cur_node).is_some().into();\n    BOOTED_DEADEND.set(is_deadend);\n\n    // Try to find all denylisted deployments in the graph too.\n    let denylisted_releases = find_denylisted_releases(&graph, denylisted_depls);\n\n    // Find all possible update targets from booted deployment.\n    let targets: Vec<_> = graph\n        .edges\n        .iter()\n        .filter_map(|(src, dst)| {\n            if *src == cur_position as u64 {\n                Some(*dst as usize)\n            } else {\n                None\n            }\n        })\n        .collect();\n    let mut updates = BTreeSet::new();\n    for pos in targets {\n        let node = match graph.nodes.get(pos) {\n            Some(n) => n.clone(),\n            None => {\n                let msg = format!(\"target node '{}' not present in graph\", pos);\n                return Err(CincinnatiError::FailedNodeLookup(msg));\n            }\n        };\n        let release = Release::from_cincinnati(node)\n            .map_err(|e| CincinnatiError::FailedNodeParsing(e.to_string()))?;\n        updates.insert(release);\n    }\n\n    // Exclude targets in denylist.\n    let new_updates = updates.difference(&denylisted_releases);\n\n    // Log that we will avoid updating to denylisted releases.\n    let prev_deployed_excluded = updates.intersection(&denylisted_releases).count();\n    if prev_deployed_excluded > 0 {\n        log::debug!(\n            \"Found {} possible update target{} present in denylist; ignoring\",\n            prev_deployed_excluded,\n            if prev_deployed_excluded > 1 { \"s\" } else { \"\" }\n        );\n    }\n    UPDATE_TARGETS_IGNORED.set(prev_deployed_excluded as i64);\n\n    // Pick highest available updates target (based on age-index).\n    let next = match new_updates.last().cloned() {\n        Some(rel) => rel,\n        None => return Ok(None),\n    };\n\n    // Check for downgrades.\n    if next <= cur_release {\n        log::warn!(\"downgrade hint towards target release '{}'\", next.version);\n        if !allow_downgrade {\n            log::warn!(\"update hint rejected, downgrades are not allowed by configuration\");\n            return Ok(None);\n        }\n    }\n\n    Ok(Some(next))\n}\n\n/// Try to match a set of (denylisted) deployments to their graph entries.\nfn find_denylisted_releases(graph: &client::Graph, depls: BTreeSet<Release>) -> BTreeSet<Release> {\n    use std::collections::HashSet;\n\n    let mut local_releases = BTreeSet::new();\n    let local_payloads: HashSet<Payload> = depls.into_iter().map(|rel| rel.payload).collect();\n\n    for entry in &graph.nodes {\n        if let Ok(release) = Release::from_cincinnati(entry.clone()) {\n            if local_payloads.contains(&release.payload) {\n                local_releases.insert(release);\n            }\n        }\n    }\n\n    local_releases\n}\n\n/// Check whether input node matches current checksum.\nfn is_same_checksum(node: &Node, deploy: &Release) -> bool {\n    match node.metadata.get(SCHEME_KEY) {\n        Some(scheme) if scheme == OCI_SCHEME => deploy.payload.whole() == node.payload,\n        _ => false,\n    }\n}\n\n/// Check whether input node is a dead-end; if so, return the reason.\n///\n/// Note: this is usually only called on the node\n/// corresponding to the booted deployment.\nfn evaluate_deadend(node: &Node) -> Option<String> {\n    let node_is_deadend = node\n        .metadata\n        .get(DEADEND_KEY)\n        .map(|v| v == \"true\")\n        .unwrap_or(false);\n\n    if !node_is_deadend {\n        return None;\n    }\n\n    let mut deadend_reason = node\n        .metadata\n        .get(DEADEND_REASON_KEY)\n        .map(|v| v.to_string())\n        .unwrap_or_default();\n    if deadend_reason.is_empty() {\n        deadend_reason = \"(unknown reason)\".to_string();\n    }\n\n    Some(deadend_reason)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::collections::HashMap;\n\n    #[test]\n    fn source_node_comparison() {\n        let current = Release {\n            version: String::new(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n            age_index: None,\n        };\n\n        let mut metadata = HashMap::new();\n        metadata.insert(SCHEME_KEY.to_string(), OCI_SCHEME.to_string());\n        let matching = Node {\n            version: \"v0\".to_string(),\n            payload: \"quay.io/fedora/fedora-coreos:oci-mock\".to_string(),\n            metadata,\n        };\n        assert!(is_same_checksum(&matching, &current));\n\n        let mismatch = Node {\n            version: \"v0\".to_string(),\n            payload: \"mismatch\".to_string(),\n            metadata: HashMap::new(),\n        };\n        assert!(!is_same_checksum(&mismatch, &current));\n    }\n\n    #[test]\n    fn deadend_node() {\n        let deadend_json = r#\"\n{\n  \"version\": \"30.20190716.1\",\n  \"metadata\": {\n    \"org.fedoraproject.coreos.releases.age_index\": \"0\",\n    \"org.fedoraproject.coreos.scheme\": \"checksum\",\n    \"org.fedoraproject.coreos.updates.deadend\": \"true\",\n    \"org.fedoraproject.coreos.updates.deadend_reason\": \"https://github.com/coreos/fedora-coreos-tracker/issues/215\"\n  },\n  \"payload\": \"ff4803b069b5a10e5bee2f6bb0027117637559d813c2016e27d57b309dd09d6f\"\n}\n\"#;\n        let deadend: Node = serde_json::from_str(deadend_json).unwrap();\n        let reason = \"https://github.com/coreos/fedora-coreos-tracker/issues/215\".to_string();\n        assert_eq!(evaluate_deadend(&deadend), Some(reason));\n\n        let common_json = r#\"\n{\n  \"version\": \"30.20190725.0\",\n  \"metadata\": {\n    \"org.fedoraproject.coreos.releases.age_index\": \"1\",\n    \"org.fedoraproject.coreos.scheme\": \"checksum\"\n  },\n  \"payload\": \"8b79877efa7ac06becd8637d95f8ca83aa385f89f383288bf3c2c31ca53216c7\"\n}\n\"#;\n\n        let common: Node = serde_json::from_str(common_json).unwrap();\n        assert_eq!(evaluate_deadend(&common), None);\n    }\n}\n"
  },
  {
    "path": "src/cli/agent.rs",
    "content": "//! Logic for the `agent` subcommand.\n\nuse super::ensure_user;\nuse crate::{config, dbus, metrics, rpm_ostree, update_agent, utils};\nuse actix::{Actor, Addr};\nuse anyhow::{Context, Result};\nuse clap::{crate_name, crate_version};\nuse log::{info, trace};\nuse prometheus::IntGauge;\nuse tokio::runtime::Runtime;\n\nlazy_static::lazy_static! {\n    static ref PROCESS_START_TIME: IntGauge = register_int_gauge!(opts!(\n        \"process_start_time_seconds\",\n        \"Start time of the process since unix epoch in seconds.\"\n    )).unwrap();\n}\n\n/// Agent subcommand entry-point.\npub(crate) fn run_agent() -> Result<()> {\n    ensure_user(\"zincati\", \"update agent not running as `zincati` user\")?;\n    info!(\n        \"starting update agent ({} {})\",\n        crate_name!(),\n        crate_version!()\n    );\n\n    // Start a new dedicated signal handling thread in a new runtime.\n    let signal_handling_rt = Runtime::new().unwrap();\n    signal_handling_rt.spawn(async {\n        use tokio::signal::unix::{signal, SignalKind};\n\n        // Create stream of terminate signals.\n        let mut stream = signal(SignalKind::terminate()).expect(\"failed to set SIGTERM handler\");\n\n        stream.recv().await;\n        // Reset status text to empty string (default).\n        utils::update_unit_status(\"\");\n        utils::notify_stopping();\n        std::process::exit(0);\n    });\n\n    let settings = config::Settings::assemble()?;\n    settings.refresh_metrics();\n    info!(\n        \"agent running on node '{}', in update group '{}'\",\n        settings.identity.node_uuid.lower_hex(),\n        settings.identity.group\n    );\n\n    // Expose process start timestamp.\n    let start_time = chrono::Utc::now();\n    PROCESS_START_TIME.set(start_time.timestamp());\n\n    trace!(\"creating actor system\");\n    let sys = actix::System::new();\n\n    // Lift the dbus_service_addr ref to a higher scope to prevent drop() from\n    // being called on it, else we'll lose the listener as soon as we create it.\n    // This previously worked without doing so because of a connection leak in zbus:\n    // https://github.com/dbus2/zbus/commit/ba2a40752dcb45a034eeda6902b59e1ac437cdcb\n    let _dbus_service_addr = sys.block_on(async {\n        trace!(\"creating metrics service\");\n        let _metrics_addr = metrics::MetricsService::bind_socket()?.start();\n\n        trace!(\"creating rpm-ostree client\");\n        let rpm_ostree_addr = rpm_ostree::RpmOstreeClient::start(1);\n\n        trace!(\"creating update agent\");\n        let agent = update_agent::UpdateAgent::with_config(settings, rpm_ostree_addr);\n        let agent_addr = agent.start();\n\n        trace!(\"creating D-Bus service\");\n        let dbus_service_addr = dbus::DBusService::start(1, agent_addr);\n\n        Ok::<Addr<dbus::DBusService>, anyhow::Error>(dbus_service_addr)\n    })?;\n\n    trace!(\"starting actor system\");\n    sys.run().context(\"agent failed\")?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/cli/deadend.rs",
    "content": "//! Logic for the `deadend` subcommand.\n\nuse super::ensure_user;\nuse anyhow::{Context, Result};\nuse clap::Subcommand;\nuse fn_error_context::context;\nuse std::fs::Permissions;\nuse std::io::Write;\nuse std::os::unix::fs::PermissionsExt;\n\n/// Absolute path to the MOTD fragments directory.\nstatic MOTD_FRAGMENTS_DIR: &str = \"/run/motd.d/\";\n/// Absolute path to the MOTD fragment with deadend state.\nstatic DEADEND_MOTD_PATH: &str = \"/run/motd.d/85-zincati-deadend.motd\";\n\n/// Subcommand `deadend-motd`.\n#[derive(Debug, Subcommand)]\npub enum Cmd {\n    /// Set deadend state, with given reason.\n    #[command(name = \"set\")]\n    Set {\n        #[arg(long = \"reason\")]\n        reason: String,\n    },\n    /// Unset deadend state.\n    #[command(name = \"unset\")]\n    Unset,\n}\n\nimpl Cmd {\n    /// `deadend-motd` subcommand entry point.\n    #[context(\"failed to run `deadend-motd` subcommand\")]\n    pub(crate) fn run(self) -> Result<()> {\n        ensure_user(\n            \"root\",\n            \"deadend-motd subcommand must be run as `root` user, \\\n             and should be called by the Zincati agent process\",\n        )?;\n        match self {\n            Cmd::Set { reason } => refresh_motd_fragment(reason),\n            Cmd::Unset => remove_motd_fragment(),\n        }\n    }\n}\n\n/// Refresh MOTD fragment with deadend reason.\nfn refresh_motd_fragment(reason: String) -> Result<()> {\n    // Avoid showing partially-written messages using tempfile and\n    // persist (rename).\n    let mut f = tempfile::Builder::new()\n        .prefix(\".deadend.\")\n        .suffix(\".motd.partial\")\n        // Create the tempfile in the same directory as the final MOTD,\n        // to ensure proper SELinux labels are applied to the tempfile\n        // before renaming.\n        .tempfile_in(MOTD_FRAGMENTS_DIR)\n        .with_context(|| {\n            format!(\n                \"failed to create temporary MOTD file under '{}'\",\n                MOTD_FRAGMENTS_DIR\n            )\n        })?;\n    // Set correct permissions of the temporary file, before moving to\n    // the destination (`tempfile` creates files with mode 0600).\n    std::fs::set_permissions(f.path(), Permissions::from_mode(0o644)).with_context(|| {\n        format!(\n            \"failed to set permissions of temporary MOTD file at '{}'\",\n            f.path().display()\n        )\n    })?;\n\n    writeln!(\n        f,\n        \"This release is a dead-end and will not further auto-update: {}\",\n        reason\n    )\n    .and_then(|_| f.flush())\n    .with_context(|| format!(\"failed to write MOTD content to '{}'\", f.path().display()))?;\n\n    f.persist(DEADEND_MOTD_PATH)\n        .with_context(|| format!(\"failed to persist MOTD fragment to '{}'\", DEADEND_MOTD_PATH))?;\n    Ok(())\n}\n\n/// Remove motd fragment file, if any.\nfn remove_motd_fragment() -> Result<()> {\n    if let Err(e) = std::fs::remove_file(DEADEND_MOTD_PATH) {\n        if e.kind() != std::io::ErrorKind::NotFound {\n            anyhow::bail!(\n                \"failed to remove MOTD fragment at '{}': {}\",\n                DEADEND_MOTD_PATH,\n                e\n            );\n        }\n    }\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::cli::{CliCommand, CliOptions};\n    use clap::Parser;\n\n    #[test]\n    fn test_deadend_motd_set() {\n        {\n            let missing_flag = vec![\"zincati\", \"deadend-motd\", \"set\"];\n            let cli = CliOptions::try_parse_from(missing_flag);\n            assert!(cli.is_err());\n        }\n        {\n            let missing_reason = vec![\"zincati\", \"deadend-motd\", \"set\", \"--reason\"];\n            let cli = CliOptions::try_parse_from(missing_reason);\n            assert!(cli.is_err());\n        }\n        {\n            let empty_reason = vec![\"zincati\", \"deadend-motd\", \"set\", \"--reason\", \"\"];\n            let cli = CliOptions::try_parse_from(empty_reason).unwrap();\n            if let CliCommand::DeadendMotd(Cmd::Set { reason }) = &cli.cmd {\n                assert_eq!(reason, \"\");\n            } else {\n                panic!(\"unexpected result: {:?}\", cli);\n            }\n        }\n        {\n            let reason_message = vec![\"zincati\", \"deadend-motd\", \"set\", \"--reason\", \"foo\"];\n            let cli = CliOptions::try_parse_from(reason_message).unwrap();\n            if let CliCommand::DeadendMotd(Cmd::Set { reason }) = &cli.cmd {\n                assert_eq!(reason, \"foo\");\n            } else {\n                panic!(\"unexpected result: {:?}\", cli);\n            }\n        }\n    }\n\n    #[test]\n    fn test_deadend_motd_unset() {\n        {\n            let extra_flags = vec![\"zincati\", \"deadend-motd\", \"unset\", \"--reason\", \"foo\"];\n            let cli = CliOptions::try_parse_from(extra_flags);\n            assert!(cli.is_err());\n        }\n        {\n            let unset = vec![\"zincati\", \"deadend-motd\", \"unset\"];\n            let cli = CliOptions::try_parse_from(unset).unwrap();\n            if !matches!(&cli.cmd, CliCommand::DeadendMotd(Cmd::Unset)) {\n                panic!(\"unexpected result: {:?}\", cli);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/cli/ex.rs",
    "content": "//! Logic for the ex subcommand.\n\nuse super::ensure_user;\nuse anyhow::Result;\nuse clap::Subcommand;\nuse fn_error_context::context;\nuse zbus::proxy;\n\n#[derive(Debug, Subcommand)]\npub enum Cmd {\n    /// Replies different cow-speak depending on whether the\n    /// talkative flag is set.\n    #[command(name = \"moo\")]\n    Moo {\n        #[arg(long)]\n        talkative: bool,\n    },\n    /// Get last refresh time of update agent actor's state.\n    #[command(name = \"last-refresh-time\")]\n    LastRefreshTime,\n}\n\nimpl Cmd {\n    /// `ex` subcommand entry point.\n    #[context(\"failed to run `ex` subcommand\")]\n    pub(crate) fn run(self) -> Result<()> {\n        ensure_user(\n            \"root\",\n            \"ex subcommand must be run as `root` user, \\\n             and should only be used for testing purposes\",\n        )?;\n        let connection = zbus::blocking::Connection::system()?;\n        let proxy = ExperimentalProxyBlocking::new(&connection)?;\n        match self {\n            Cmd::Moo { talkative } => {\n                println!(\"{}\", proxy.moo(talkative)?);\n                Ok(())\n            }\n            Cmd::LastRefreshTime => {\n                println!(\"{}\", proxy.last_refresh_time()?);\n                Ok(())\n            }\n        }\n    }\n}\n\n#[proxy(\n    interface = \"org.coreos.zincati.Experimental\",\n    default_service = \"org.coreos.zincati\",\n    default_path = \"/org/coreos/zincati\"\n)]\ntrait Experimental {\n    /// LastRefreshTime method\n    fn last_refresh_time(&self) -> zbus::Result<i64>;\n\n    /// Moo method\n    fn moo(&self, talkative: bool) -> zbus::Result<String>;\n}\n"
  },
  {
    "path": "src/cli/mod.rs",
    "content": "//! Command-Line Interface (CLI) logic.\n\nmod agent;\nmod deadend;\nmod ex;\n\nuse anyhow::Result;\nuse clap::{ArgAction, Parser};\nuse log::LevelFilter;\nuse users::get_current_username;\n\n/// CLI configuration options.\n#[derive(Debug, Parser)]\npub(crate) struct CliOptions {\n    /// Verbosity level (higher is more verbose).\n    #[arg(action = ArgAction::Count, short = 'v', global = true)]\n    verbosity: u8,\n\n    /// CLI sub-command.\n    #[clap(subcommand)]\n    pub(crate) cmd: CliCommand,\n}\n\nimpl CliOptions {\n    /// Returns the log-level set via command-line flags.\n    pub(crate) fn loglevel(&self) -> LevelFilter {\n        match self.verbosity {\n            0 => LevelFilter::Warn,\n            1 => LevelFilter::Info,\n            2 => LevelFilter::Debug,\n            _ => LevelFilter::Trace,\n        }\n    }\n\n    /// Dispatch CLI subcommand.\n    pub(crate) fn run(self) -> Result<()> {\n        match self.cmd {\n            CliCommand::Agent => agent::run_agent(),\n            CliCommand::DeadendMotd(cmd) => cmd.run(),\n            CliCommand::Ex(cmd) => cmd.run(),\n        }\n    }\n}\n\n/// CLI sub-commands.\n#[derive(Debug, Parser)]\n#[command(rename_all = \"kebab-case\")]\npub(crate) enum CliCommand {\n    /// Long-running agent for auto-updates.\n    Agent,\n    /// Set or unset deadend MOTD state.\n    #[command(hide = true, subcommand)]\n    DeadendMotd(deadend::Cmd),\n    /// Print update agent state's last refresh time.\n    #[command(hide = true, subcommand)]\n    Ex(ex::Cmd),\n}\n\n/// Return Error with msg if not run by user.\nfn ensure_user(user: &str, msg: &str) -> Result<()> {\n    if let Some(uname) = get_current_username() {\n        if uname == user {\n            return Ok(());\n        }\n    }\n\n    anyhow::bail!(\"{}\", msg)\n}\n"
  },
  {
    "path": "src/config/fragments.rs",
    "content": "//! TOML configuration fragments.\n\nuse ordered_float::NotNan;\nuse serde::Deserialize;\nuse std::collections::BTreeSet;\nuse std::num::NonZeroU64;\n\n/// Top-level configuration stanza.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct ConfigFragment {\n    /// Agent configuration.\n    pub(crate) agent: Option<AgentFragment>,\n    /// Cincinnati client configuration.\n    pub(crate) cincinnati: Option<CincinnatiFragment>,\n    /// Agent identity.\n    pub(crate) identity: Option<IdentityFragment>,\n    /// Update strategy configuration.\n    pub(crate) updates: Option<UpdateFragment>,\n}\n\n/// Config fragment for agent settings.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct AgentFragment {\n    /// Timing settings for the agent.\n    pub(crate) timing: Option<AgentTiming>,\n}\n\n/// Config fragment for agent timing.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct AgentTiming {\n    /// Pausing interval between updates checks in steady mode, in seconds (default: 300).\n    pub(crate) steady_interval_secs: Option<NonZeroU64>,\n}\n\n// Config fragment for agent identity.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct IdentityFragment {\n    /// Update group for this agent (default: 'default')\n    pub(crate) group: Option<String>,\n    /// Update group for this agent (default: derived from machine-id)\n    pub(crate) node_uuid: Option<String>,\n    /// Update group for this agent (default: derived server-side)\n    pub(crate) rollout_wariness: Option<NotNan<f64>>,\n}\n\n/// Config fragment for Cincinnati client.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct CincinnatiFragment {\n    /// Base URL to upstream cincinnati server.\n    pub(crate) base_url: Option<String>,\n}\n\n/// Config fragment for update logic.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct UpdateFragment {\n    /// Whether to enable automatic downgrades.\n    pub(crate) allow_downgrade: Option<bool>,\n    /// Whether to enable auto-updates logic.\n    pub(crate) enabled: Option<bool>,\n    /// Update strategy (default: immediate).\n    pub(crate) strategy: Option<String>,\n    /// `fleet_lock` strategy config.\n    pub(crate) fleet_lock: Option<UpdateFleetLock>,\n    /// `periodic` strategy config.\n    pub(crate) periodic: Option<UpdatePeriodic>,\n}\n\n/// Config fragment for `fleet_lock` update strategy.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct UpdateFleetLock {\n    /// Base URL for the remote semaphore manager.\n    pub(crate) base_url: Option<String>,\n}\n\n/// Config fragment for `periodic` update strategy.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct UpdatePeriodic {\n    /// A weekly window.\n    pub(crate) window: Option<Vec<UpdatePeriodicWindow>>,\n    /// A time zone in the IANA Time Zone Database (https://www.iana.org/time-zones)\n    /// or \"localtime\". If unset, UTC is used.\n    ///\n    /// Examples: `America/Toronto`, `Europe/Rome`\n    pub(crate) time_zone: Option<String>,\n}\n\n/// Config fragment for a `periodic.window` entry.\n#[derive(Debug, Deserialize, PartialEq, Eq)]\npub(crate) struct UpdatePeriodicWindow {\n    /// Weekdays (English names).\n    pub(crate) days: BTreeSet<String>,\n    /// Start time (`hh:mm` 24h format).\n    pub(crate) start_time: String,\n    /// Window length in minutes.\n    pub(crate) length_minutes: u32,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use maplit::btreeset;\n\n    #[test]\n    fn basic_dist_config_sample() {\n        let content = std::fs::read_to_string(\"tests/fixtures/00-config-sample.toml\").unwrap();\n        let cfg: ConfigFragment = toml::from_str(&content).unwrap();\n\n        let expected = ConfigFragment {\n            agent: Some(AgentFragment {\n                timing: Some(AgentTiming {\n                    steady_interval_secs: Some(NonZeroU64::new(35).unwrap()),\n                }),\n            }),\n            cincinnati: Some(CincinnatiFragment {\n                base_url: Some(\"http://cincinnati.example.com:80/\".to_string()),\n            }),\n            identity: Some(IdentityFragment {\n                group: Some(\"workers\".to_string()),\n                node_uuid: Some(\"27e3ac02af3946af995c9940e18b0cce\".to_string()),\n                rollout_wariness: Some(NotNan::new(0.5).unwrap()),\n            }),\n            updates: Some(UpdateFragment {\n                allow_downgrade: Some(true),\n                enabled: Some(false),\n                strategy: Some(\"fleet_lock\".to_string()),\n                fleet_lock: Some(UpdateFleetLock {\n                    base_url: Some(\"http://fleet-lock.example.com:8080/\".to_string()),\n                }),\n                periodic: Some(UpdatePeriodic {\n                    window: Some(vec![\n                        UpdatePeriodicWindow {\n                            days: btreeset!(\"Sat\".to_string(), \"Sun\".to_string()),\n                            start_time: \"23:00\".to_string(),\n                            length_minutes: 120,\n                        },\n                        UpdatePeriodicWindow {\n                            days: btreeset!(\"Wed\".to_string()),\n                            start_time: \"23:30\".to_string(),\n                            length_minutes: 25,\n                        },\n                    ]),\n                    time_zone: Some(\"localtime\".to_string()),\n                }),\n            }),\n        };\n\n        assert_eq!(cfg, expected);\n    }\n}\n"
  },
  {
    "path": "src/config/inputs.rs",
    "content": "use crate::config::fragments;\nuse crate::update_agent::DEFAULT_STEADY_INTERVAL_SECS;\nuse anyhow::{Context, Result};\nuse fn_error_context::context;\nuse log::trace;\nuse ordered_float::NotNan;\nuse serde::Serialize;\nuse std::num::NonZeroU64;\n\n/// Runtime configuration holding environmental inputs.\n#[derive(Debug, Serialize)]\npub(crate) struct ConfigInput {\n    pub(crate) agent: AgentInput,\n    pub(crate) cincinnati: CincinnatiInput,\n    pub(crate) updates: UpdateInput,\n    pub(crate) identity: IdentityInput,\n}\n\nimpl ConfigInput {\n    /// Read config fragments and merge them into a single config.\n    #[context(\"failed to read and merge config fragments\")]\n    pub(crate) fn read_configs(\n        dirs: Vec<String>,\n        common_path: &str,\n        extensions: Vec<String>,\n    ) -> Result<Self> {\n        let mut fragments = Vec::new();\n        for (_, fpath) in liboverdrop::scan(dirs, common_path, extensions.as_slice(), true) {\n            trace!(\"reading config fragment '{}'\", fpath.display());\n\n            let content = std::fs::read_to_string(&fpath)\n                .with_context(|| format!(\"failed to read file '{}'\", fpath.display()))?;\n            let frag: fragments::ConfigFragment =\n                toml::from_str(&content).context(\"failed to parse TOML\")?;\n\n            fragments.push(frag);\n        }\n\n        let cfg = Self::merge_fragments(fragments);\n        Ok(cfg)\n    }\n\n    /// Merge multiple fragments into a single configuration.\n    pub(crate) fn merge_fragments(fragments: Vec<fragments::ConfigFragment>) -> Self {\n        let mut agents = vec![];\n        let mut cincinnatis = vec![];\n        let mut updates = vec![];\n        let mut identities = vec![];\n\n        for snip in fragments {\n            if let Some(a) = snip.agent {\n                agents.push(a);\n            }\n            if let Some(c) = snip.cincinnati {\n                cincinnatis.push(c);\n            }\n            if let Some(f) = snip.updates {\n                updates.push(f);\n            }\n            if let Some(i) = snip.identity {\n                identities.push(i);\n            }\n        }\n\n        Self {\n            agent: AgentInput::from_fragments(agents),\n            cincinnati: CincinnatiInput::from_fragments(cincinnatis),\n            updates: UpdateInput::from_fragments(updates),\n            identity: IdentityInput::from_fragments(identities),\n        }\n    }\n}\n\n/// Config for the agent.\n#[derive(Debug, Serialize)]\npub(crate) struct AgentInput {\n    pub(crate) steady_interval_secs: NonZeroU64,\n}\n\nimpl AgentInput {\n    fn from_fragments(fragments: Vec<fragments::AgentFragment>) -> Self {\n        let mut cfg = Self {\n            steady_interval_secs: NonZeroU64::new(DEFAULT_STEADY_INTERVAL_SECS)\n                .expect(\"non-zero interval\"),\n        };\n\n        for snip in fragments {\n            if let Some(timing) = snip.timing {\n                if let Some(s) = timing.steady_interval_secs {\n                    cfg.steady_interval_secs = s;\n                }\n            }\n        }\n\n        cfg\n    }\n}\n\n#[derive(Clone, Debug, Serialize)]\npub(crate) struct CincinnatiInput {\n    /// Base URL (template) for the Cincinnati service.\n    pub(crate) base_url: String,\n}\n\nimpl CincinnatiInput {\n    fn from_fragments(fragments: Vec<fragments::CincinnatiFragment>) -> Self {\n        let mut cfg = Self {\n            base_url: String::new(),\n        };\n\n        for snip in fragments {\n            if let Some(u) = snip.base_url {\n                cfg.base_url = u;\n            }\n        }\n\n        cfg\n    }\n}\n\n#[derive(Debug, Serialize)]\npub(crate) struct IdentityInput {\n    pub(crate) group: String,\n    pub(crate) node_uuid: String,\n    pub(crate) rollout_wariness: Option<NotNan<f64>>,\n}\n\nimpl IdentityInput {\n    fn from_fragments(fragments: Vec<fragments::IdentityFragment>) -> Self {\n        let mut cfg = Self {\n            group: String::new(),\n            node_uuid: String::new(),\n            rollout_wariness: None,\n        };\n\n        for snip in fragments {\n            if let Some(g) = snip.group {\n                cfg.group = g;\n            }\n            if let Some(nu) = snip.node_uuid {\n                cfg.node_uuid = nu;\n            }\n            if let Some(rw) = snip.rollout_wariness {\n                cfg.rollout_wariness = Some(rw);\n            }\n        }\n\n        cfg\n    }\n}\n\n/// Config for update logic.\n#[derive(Debug, Serialize)]\npub(crate) struct UpdateInput {\n    /// Whether to enable automatic downgrades.\n    pub(crate) allow_downgrade: bool,\n    /// Whether to enable auto-updates logic.\n    pub(crate) enabled: bool,\n    /// Update strategy.\n    pub(crate) strategy: String,\n    /// `fleet_lock` strategy config.\n    pub(crate) fleet_lock: FleetLockInput,\n    /// `periodic` strategy config.\n    pub(crate) periodic: PeriodicInput,\n}\n\n/// Config for \"fleet_lock\" strategy.\n#[derive(Clone, Debug, Serialize)]\npub(crate) struct FleetLockInput {\n    /// Base URL (template) for the FleetLock service.\n    pub(crate) base_url: String,\n}\n\n/// Config for \"periodic\" strategy.\n#[derive(Clone, Debug, Serialize)]\npub(crate) struct PeriodicInput {\n    /// Set of updates windows.\n    pub(crate) intervals: Vec<PeriodicIntervalInput>,\n    /// A time zone in the IANA Time Zone Database or \"localtime\".\n    /// Defaults to \"UTC\".\n    pub(crate) time_zone: String,\n}\n\n/// Update window for a \"periodic\" interval.\n#[derive(Clone, Debug, Serialize)]\npub(crate) struct PeriodicIntervalInput {\n    pub(crate) start_day: String,\n    pub(crate) start_time: String,\n    pub(crate) length_minutes: u32,\n}\n\nimpl UpdateInput {\n    fn from_fragments(fragments: Vec<fragments::UpdateFragment>) -> Self {\n        let mut allow_downgrade = false;\n        let mut enabled = true;\n        let mut strategy = String::new();\n        let mut fleet_lock = FleetLockInput {\n            base_url: String::new(),\n        };\n        let mut periodic = PeriodicInput {\n            intervals: vec![],\n            time_zone: \"UTC\".to_string(),\n        };\n\n        for snip in fragments {\n            if let Some(a) = snip.allow_downgrade {\n                allow_downgrade = a;\n            }\n            if let Some(e) = snip.enabled {\n                enabled = e;\n            }\n            if let Some(s) = snip.strategy {\n                strategy = s;\n            }\n            if let Some(fl) = snip.fleet_lock {\n                if let Some(b) = fl.base_url {\n                    fleet_lock.base_url = b;\n                }\n            }\n            if let Some(w) = snip.periodic {\n                if let Some(tz) = w.time_zone {\n                    periodic.time_zone = tz;\n                }\n                if let Some(win) = w.window {\n                    for entry in win {\n                        for day in entry.days {\n                            let interval = PeriodicIntervalInput {\n                                start_day: day,\n                                start_time: entry.start_time.clone(),\n                                length_minutes: entry.length_minutes,\n                            };\n                            periodic.intervals.push(interval);\n                        }\n                    }\n                }\n            }\n        }\n\n        Self {\n            allow_downgrade,\n            enabled,\n            strategy,\n            fleet_lock,\n            periodic,\n        }\n    }\n}\n"
  },
  {
    "path": "src/config/mod.rs",
    "content": "//! Configuration parsing and validation.\n//!\n//! This module contains the following logical entities:\n//!  * Fragments: TOML configuration entries.\n//!  * Inputs: configuration fragments merged, but not yet validated.\n//!  * Settings: validated settings for the agent.\n\n/// TOML structures.\npub(crate) mod fragments;\n\n/// Configuration fragments.\npub(crate) mod inputs;\n\nuse crate::cincinnati::Cincinnati;\nuse crate::identity::Identity;\nuse crate::strategy::UpdateStrategy;\nuse crate::update_agent;\nuse anyhow::Result;\nuse clap::crate_name;\nuse fn_error_context::context;\nuse serde::Serialize;\nuse std::num::NonZeroU64;\n\n/// Runtime configuration for the agent.\n///\n/// It holds validated agent configuration.\n#[derive(Debug, Serialize)]\npub(crate) struct Settings {\n    /// Whether to enable automatic downgrades.\n    pub(crate) allow_downgrade: bool,\n    /// Whether to enable auto-updates logic.\n    pub(crate) enabled: bool,\n    /// Agent timing, steady state refresh period.\n    pub(crate) steady_interval_secs: NonZeroU64,\n    /// Cincinnati configuration.\n    pub(crate) cincinnati: Cincinnati,\n    /// Agent configuration.\n    pub(crate) identity: Identity,\n    /// Agent update strategy.\n    pub(crate) strategy: UpdateStrategy,\n}\n\nimpl Settings {\n    /// Assemble runtime settings.\n    #[context(\"failed to assemble configuration settings\")]\n    pub(crate) fn assemble() -> Result<Self> {\n        let prefixes = vec![\n            \"/usr/lib/\".to_string(),\n            \"/run/\".to_string(),\n            \"/etc/\".to_string(),\n        ];\n        let common_path = format!(\"{}/config.d/\", crate_name!());\n        let extensions = vec![\"toml\".to_string()];\n        let cfg = inputs::ConfigInput::read_configs(prefixes, &common_path, extensions)?;\n        Self::validate(cfg)\n    }\n\n    /// Refresh settings-related metrics values.\n    pub(crate) fn refresh_metrics(&self) {\n        // TODO(lucab): consider adding more metrics here (e.g. steady interval).\n        update_agent::UPDATES_ENABLED.set(i64::from(self.enabled));\n        update_agent::ALLOW_DOWNGRADE.set(i64::from(self.allow_downgrade));\n\n        self.strategy.refresh_metrics();\n    }\n\n    /// Validate config and return a valid agent settings.\n    fn validate(cfg: inputs::ConfigInput) -> Result<Self> {\n        let allow_downgrade = cfg.updates.allow_downgrade;\n        let enabled = cfg.updates.enabled;\n        let steady_interval_secs = cfg.agent.steady_interval_secs;\n        let identity = Identity::with_config(cfg.identity)?;\n        let strategy = UpdateStrategy::with_config(cfg.updates, &identity)?;\n        let cincinnati = Cincinnati::with_config(cfg.cincinnati, &identity)?;\n\n        Ok(Self {\n            allow_downgrade,\n            enabled,\n            steady_interval_secs,\n            cincinnati,\n            identity,\n            strategy,\n        })\n    }\n}\n"
  },
  {
    "path": "src/dbus/experimental.rs",
    "content": "//! Experimental interface.\n\nuse crate::update_agent::{LastRefresh, UpdateAgent};\nuse actix::Addr;\nuse futures::prelude::*;\nuse tokio::runtime::Runtime;\nuse zbus::{fdo, interface};\n\n/// Experimental interface for testing.\npub(crate) struct Experimental {\n    pub(crate) agent_addr: Addr<UpdateAgent>,\n}\n\n#[interface(name = \"org.coreos.zincati.Experimental\")]\nimpl Experimental {\n    /// Just a test method.\n    fn moo(&self, talkative: bool) -> String {\n        if talkative {\n            String::from(\"Moooo mooo moooo!\")\n        } else {\n            String::from(\"moo.\")\n        }\n    }\n\n    /// Get update_agent actor's last refresh time.\n    fn last_refresh_time(&self) -> fdo::Result<i64> {\n        let msg = LastRefresh {};\n        let refresh_time_fut = self.agent_addr.send(msg).map_err(|e| {\n            let err_msg = format!(\"failed to get last refresh time from agent actor: {}\", e);\n            log::error!(\"LastRefreshTime D-Bus method call: {}\", err_msg);\n            fdo::Error::Failed(err_msg)\n        });\n\n        Runtime::new()\n            .map_err(|e| {\n                let err_msg = format!(\"failed to create runtime to execute future: {}\", e);\n                log::error!(\"{}\", err_msg);\n                fdo::Error::Failed(err_msg)\n            })\n            .and_then(|runtime| runtime.block_on(refresh_time_fut))\n    }\n}\n"
  },
  {
    "path": "src/dbus/mod.rs",
    "content": "//! D-Bus service actor.\n\nmod experimental;\nuse experimental::Experimental;\n\nuse crate::update_agent::UpdateAgent;\nuse actix::prelude::*;\nuse actix::Addr;\nuse anyhow::Result;\nuse fn_error_context::context;\nuse log::trace;\nuse zbus::blocking::{connection, Connection};\n\npub struct DBusService {\n    agent_addr: Addr<UpdateAgent>,\n    connection: Option<Connection>,\n}\n\nimpl DBusService {\n    /// Create new DBusService\n    fn new(agent_addr: Addr<UpdateAgent>) -> DBusService {\n        DBusService {\n            agent_addr,\n            connection: None,\n        }\n    }\n\n    /// Start the threadpool for DBusService actor.\n    pub(crate) fn start(threads: usize, agent_addr: Addr<UpdateAgent>) -> Addr<Self> {\n        SyncArbiter::start(threads, move || DBusService::new(agent_addr.clone()))\n    }\n\n    #[context(\"failed to start object server\")]\n    fn start_object_server(&mut self) -> Result<Connection> {\n        let connection = connection::Builder::system()?\n            .allow_name_replacements(true)\n            .replace_existing_names(true)\n            .name(\"org.coreos.zincati\")?\n            .serve_at(\n                \"/org/coreos/zincati\",\n                Experimental {\n                    agent_addr: self.agent_addr.clone(),\n                },\n            )?\n            .build()?;\n\n        Ok(connection)\n    }\n}\n\nimpl Actor for DBusService {\n    type Context = SyncContext<Self>;\n\n    fn started(&mut self, _ctx: &mut Self::Context) {\n        trace!(\"D-Bus service actor started\");\n\n        if let Some(conn) = self.connection.take() {\n            drop(conn);\n        }\n\n        match self.start_object_server() {\n            Err(err) => log::error!(\"failed to start D-Bus service actor: {}\", err),\n            Ok(conn) => self.connection = Some(conn),\n        };\n    }\n}\n"
  },
  {
    "path": "src/fleet_lock/mock_tests.rs",
    "content": "use crate::fleet_lock::*;\nuse crate::identity::Identity;\nuse mockito::Matcher;\nuse tokio::runtime as rt;\n\n#[test]\nfn test_pre_reboot_lock() {\n    let mut server = mockito::Server::new();\n    let body = r#\"\n{\n  \"client_params\": {\n    \"id\": \"e0f3745b108f471cbd4883c6fbed8cdd\",\n    \"group\": \"mock-workers\"\n  }\n}\n\"#;\n\n    let m_pre_reboot = server\n        .mock(\"POST\", Matcher::Exact(format!(\"/{}\", V1_PRE_REBOOT)))\n        .match_header(\"fleet-lock-protocol\", \"true\")\n        .match_body(Matcher::PartialJsonString(body.to_string()))\n        .with_status(200)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = ClientBuilder::new(server.url(), &id).build().unwrap();\n    let res = runtime.block_on(client.pre_reboot());\n    m_pre_reboot.assert();\n\n    let lock = res.unwrap();\n    assert!(lock);\n}\n\n#[test]\nfn test_pre_reboot_error() {\n    let mut server = mockito::Server::new();\n    let body = r#\"\n{\n  \"kind\": \"f1\",\n  \"value\": \"pre-reboot failure\"\n}\n\"#;\n    let m_pre_reboot = server\n        .mock(\"POST\", Matcher::Exact(format!(\"/{}\", V1_PRE_REBOOT)))\n        .match_header(\"fleet-lock-protocol\", \"true\")\n        .with_status(404)\n        .with_body(body)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = ClientBuilder::new(server.url(), &id).build().unwrap();\n    let res = runtime.block_on(client.pre_reboot());\n    m_pre_reboot.assert();\n\n    let _rejection = res.unwrap_err();\n}\n\n#[test]\nfn test_steady_state_lock() {\n    let mut server = mockito::Server::new();\n    let body = r#\"\n{\n  \"client_params\": {\n    \"id\": \"e0f3745b108f471cbd4883c6fbed8cdd\",\n    \"group\": \"mock-workers\"\n  }\n}\n\"#;\n    let m_steady_state = server\n        .mock(\"POST\", Matcher::Exact(format!(\"/{}\", V1_STEADY_STATE)))\n        .match_header(\"fleet-lock-protocol\", \"true\")\n        .match_body(Matcher::PartialJsonString(body.to_string()))\n        .with_status(200)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = ClientBuilder::new(server.url(), &id).build().unwrap();\n    let res = runtime.block_on(client.steady_state());\n    m_steady_state.assert();\n\n    let unlock = res.unwrap();\n    assert!(unlock);\n}\n\n#[test]\nfn test_steady_state_error() {\n    let mut server = mockito::Server::new();\n    let body = r#\"\n{\n  \"kind\": \"f1\",\n  \"value\": \"pre-reboot failure\"\n}\n\"#;\n    let m_steady_state = server\n        .mock(\"POST\", Matcher::Exact(format!(\"/{}\", V1_STEADY_STATE)))\n        .match_header(\"fleet-lock-protocol\", \"true\")\n        .with_status(404)\n        .with_body(body)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = ClientBuilder::new(server.url(), &id).build().unwrap();\n    let res = runtime.block_on(client.steady_state());\n    m_steady_state.assert();\n\n    let _rejection = res.unwrap_err();\n}\n"
  },
  {
    "path": "src/fleet_lock/mod.rs",
    "content": "//! Asynchronous FleetLock client, remote lock management.\n//!\n//! This module implements a client for FleetLock, a bare HTTP\n//! protocol for managing cluster-wide reboot via a remote\n//! lock manager. Protocol specification is available at\n//! https://coreos.github.io/zincati/development/fleetlock/protocol/ .\n\nuse crate::identity::Identity;\nuse anyhow::{Context, Result};\nuse futures::prelude::*;\nuse reqwest::Method;\nuse serde::{Deserialize, Serialize};\nuse std::time::Duration;\nuse thiserror::Error;\n\n#[cfg(test)]\nmod mock_tests;\n\n/// Default timeout for HTTP requests completion (30 minutes).\nconst DEFAULT_HTTP_COMPLETION_TIMEOUT: Duration = Duration::from_secs(30 * 60);\n\n/// FleetLock pre-reboot API path endpoint (v1).\nstatic V1_PRE_REBOOT: &str = \"v1/pre-reboot\";\n\n/// FleetLock steady-state API path endpoint (v1).\nstatic V1_STEADY_STATE: &str = \"v1/steady-state\";\n\n/// FleetLock JSON protocol: service error.\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]\npub struct RemoteJsonError {\n    /// Machine-friendly brief error kind.\n    kind: String,\n    /// Human-friendly detailed error explanation.\n    value: String,\n}\n\n/// Error related to the FleetLock service.\n#[derive(Clone, Debug, Error, PartialEq, Eq)]\npub enum FleetLockError {\n    /// Remote endpoint error.\n    Remote(reqwest::StatusCode, RemoteJsonError),\n    /// Generic HTTP error.\n    Http(reqwest::StatusCode),\n    /// Client builder failed.\n    FailedClientBuilder(String),\n    /// Client failed request.\n    FailedRequest(String),\n}\n\nimpl FleetLockError {\n    /// Return the machine-friendly brief error kind.\n    pub fn error_kind(&self) -> String {\n        match *self {\n            FleetLockError::Remote(_, ref err) => err.kind.clone(),\n            FleetLockError::Http(status) => format!(\"generic_http_{}\", status.as_u16()),\n            FleetLockError::FailedClientBuilder(_) => \"client_failed_build\".to_string(),\n            FleetLockError::FailedRequest(_) => \"client_failed_request\".to_string(),\n        }\n    }\n\n    /// Return the human-friendly detailed error explanation.\n    pub fn error_value(&self) -> String {\n        match *self {\n            FleetLockError::Remote(_, ref err) => err.value.clone(),\n            FleetLockError::Http(_) => \"(unknown/generic server error)\".to_string(),\n            FleetLockError::FailedClientBuilder(ref err)\n            | FleetLockError::FailedRequest(ref err) => err.clone(),\n        }\n    }\n\n    /// Return the server-side error status code, if any.\n    pub fn status_code(&self) -> Option<u16> {\n        match *self {\n            FleetLockError::Remote(s, _) | FleetLockError::Http(s) => Some(s.as_u16()),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for FleetLockError {\n    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n        // Account for both server-side and client-side failures.\n        let context = match self.status_code() {\n            Some(s) => format!(\"server-side error, code {}\", s),\n            None => \"client-side error\".to_string(),\n        };\n        write!(f, \"{}: {}\", context, self.error_value())\n    }\n}\n\n/// Client to make outgoing API requests.\n#[derive(Clone, Debug, Serialize)]\npub struct Client {\n    /// Base URL for API endpoint.\n    #[serde(skip)]\n    api_base: reqwest::Url,\n    /// Asynchronous reqwest client.\n    #[serde(skip)]\n    hclient: reqwest::Client,\n    /// Request body.\n    body: String,\n}\n\nimpl Client {\n    /// Try to lock a semaphore slot on the remote manager.\n    ///\n    /// It returns `true` if the operation succeeds, or a `FleetLockError`\n    /// with the relevant error explanation.\n    pub fn pre_reboot(&self) -> impl Future<Output = Result<bool, FleetLockError>> {\n        let req = self\n            .new_request(Method::POST, V1_PRE_REBOOT)\n            .map_err(|e| FleetLockError::FailedClientBuilder(e.to_string()));\n\n        futures::future::ready(req)\n            .and_then(|req| {\n                req.send()\n                    .map_err(|e| FleetLockError::FailedRequest(e.to_string()))\n            })\n            .and_then(Self::map_response)\n    }\n\n    /// Try to unlock a semaphore slot on the remote manager.\n    ///\n    /// It returns `true` if the operation succeeds, or a `FleetLockError`\n    /// with the relevant error explanation.\n    pub fn steady_state(&self) -> impl Future<Output = Result<bool, FleetLockError>> {\n        let req = self\n            .new_request(Method::POST, V1_STEADY_STATE)\n            .map_err(|e| FleetLockError::FailedClientBuilder(e.to_string()));\n\n        futures::future::ready(req)\n            .and_then(|req| {\n                req.send()\n                    .map_err(|e| FleetLockError::FailedRequest(e.to_string()))\n            })\n            .and_then(Self::map_response)\n    }\n\n    /// Return a request builder for the target URL, with proper parameters set.\n    fn new_request<S: AsRef<str>>(\n        &self,\n        method: reqwest::Method,\n        url_suffix: S,\n    ) -> Result<reqwest::RequestBuilder> {\n        let url = self.api_base.clone().join(url_suffix.as_ref())?;\n        let builder = self\n            .hclient\n            .request(method, url)\n            .body(self.body.clone())\n            .header(\"fleet-lock-protocol\", \"true\");\n        Ok(builder)\n    }\n\n    /// Map an HTTP response to a service result.\n    async fn map_response(response: reqwest::Response) -> Result<bool, FleetLockError> {\n        // On success, short-circuit to `true`.\n        let status = response.status();\n        if status.is_success() {\n            return Ok(true);\n        }\n\n        // On error, decode failure details (or synthesize a generic error).\n        match response.json::<RemoteJsonError>().await {\n            Ok(rej) => Err(FleetLockError::Remote(status, rej)),\n            _ => Err(FleetLockError::Http(status)),\n        }\n    }\n}\n\n/// Client builder.\n#[derive(Clone, Debug)]\npub struct ClientBuilder {\n    /// Base URL for API endpoint (mandatory).\n    api_base: String,\n    /// Asynchronous reqwest client (custom).\n    hclient: Option<reqwest::Client>,\n    /// Client identity.\n    client_identity: ClientIdentity,\n}\n\n/// Client identity, for requests body.\n#[derive(Clone, Debug, Serialize)]\npub struct ClientIdentity {\n    client_params: ClientParameters,\n}\n\n/// Client parameters.\n#[derive(Clone, Debug, Serialize)]\npub struct ClientParameters {\n    /// Node identifier, for lock ownership.\n    id: String,\n    /// Reboot group, for role-specific remote configuration.\n    group: String,\n}\n\nimpl ClientBuilder {\n    /// Return a new client builder for the given base API endpoint URL.\n    pub(crate) fn new<T>(api_base: T, identity: &Identity) -> Self\n    where\n        T: Into<String>,\n    {\n        Self {\n            api_base: api_base.into(),\n            hclient: None,\n            client_identity: ClientIdentity {\n                client_params: ClientParameters {\n                    id: identity.node_uuid.lower_hex(),\n                    group: identity.group.clone(),\n                },\n            },\n        }\n    }\n\n    /// Set (or reset) the HTTP client to use.\n    #[allow(dead_code)]\n    pub fn http_client(self, hclient: Option<reqwest::Client>) -> Self {\n        let mut builder = self;\n        builder.hclient = hclient;\n        builder\n    }\n\n    /// Build a client with specified parameters.\n    pub fn build(self) -> Result<Client> {\n        let hclient = match self.hclient {\n            Some(client) => client,\n            None => reqwest::ClientBuilder::new()\n                .timeout(DEFAULT_HTTP_COMPLETION_TIMEOUT)\n                .build()?,\n        };\n\n        let api_base = reqwest::Url::parse(&self.api_base)\n            .context(format!(\"failed to parse '{}'\", &self.api_base))?;\n        if self.client_identity.client_params.group.is_empty() {\n            anyhow::bail!(\"missing group value\");\n        }\n        let body = serde_json::to_string_pretty(&self.client_identity)?;\n        let client = Client {\n            api_base,\n            hclient,\n            body,\n        };\n        Ok(client)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use http::response::Response;\n    use http::status::StatusCode;\n    use tokio::runtime as rt;\n\n    #[test]\n    fn test_service_rejection_display() {\n        let err_body = r#\"\n{\n  \"kind\": \"failure_foo\",\n  \"value\": \"failed to perform foo\"\n}\n\"#;\n        let runtime = rt::Runtime::new().unwrap();\n        let response = Response::builder().status(466).body(err_body).unwrap();\n        let fut_rejection = Client::map_response(response.into());\n        let rejection = runtime.block_on(fut_rejection).unwrap_err();\n        let expected_rejection = FleetLockError::Remote(\n            StatusCode::from_u16(466).unwrap(),\n            RemoteJsonError {\n                kind: \"failure_foo\".to_string(),\n                value: \"failed to perform foo\".to_string(),\n            },\n        );\n        assert_eq!(&rejection, &expected_rejection);\n\n        let msg = rejection.to_string();\n        let expected_msg = \"server-side error, code 466: failed to perform foo\";\n        assert_eq!(&msg, expected_msg);\n    }\n\n    #[test]\n    fn test_http_error_display() {\n        let runtime = rt::Runtime::new().unwrap();\n        let response = Response::builder().status(433).body(\"\").unwrap();\n        let fut_rejection = Client::map_response(response.into());\n        let rejection = runtime.block_on(fut_rejection).unwrap_err();\n        let expected_rejection = FleetLockError::Http(StatusCode::from_u16(433).unwrap());\n        assert_eq!(&rejection, &expected_rejection);\n\n        let msg = rejection.to_string();\n        let expected_msg = \"server-side error, code 433: (unknown/generic server error)\";\n        assert_eq!(&msg, expected_msg);\n    }\n}\n"
  },
  {
    "path": "src/identity/mod.rs",
    "content": "mod platform;\n\nuse crate::config::inputs;\nuse crate::rpm_ostree;\nuse anyhow::{anyhow, ensure, Context, Result};\nuse fn_error_context::context;\nuse lazy_static::lazy_static;\nuse libsystemd::id128;\nuse ordered_float::NotNan;\nuse prometheus::{Gauge, IntGaugeVec};\nuse regex::Regex;\nuse serde::Serialize;\nuse std::collections::HashMap;\n\n/// Default group for reboot management.\nstatic DEFAULT_GROUP: &str = \"default\";\n\n/// Application ID (`de35106b6ec24688b63afddaa156679b`)\nstatic APP_ID: &[u8] = &[\n    0xde, 0x35, 0x10, 0x6b, 0x6e, 0xc2, 0x46, 0x88, 0xb6, 0x3a, 0xfd, 0xda, 0xa1, 0x56, 0x67, 0x9b,\n];\n\nlazy_static::lazy_static! {\n    static ref ROLLOUT_WARINESS: Gauge = Gauge::new(\n        \"zincati_identity_rollout_wariness\",\n        \"Client wariness for updates rollout\"\n    ).unwrap();\n    static ref OS_INFO: IntGaugeVec = register_int_gauge_vec!(\n        \"zincati_identity_os_info\",\n        \"Information about the underlying booted OS\",\n        &[\"os_version\", \"basearch\", \"stream\", \"platform\"]\n    ).unwrap();\n}\n\n/// Agent identity.\n#[derive(Debug, Serialize, Clone)]\npub(crate) struct Identity {\n    /// OS base architecture.\n    pub(crate) basearch: String,\n    /// Current OS (version and deployment base-checksum).\n    pub(crate) current_os: rpm_ostree::Release,\n    /// Update group.\n    pub(crate) group: String,\n    /// Unique node identifier.\n    pub(crate) node_uuid: id128::Id128,\n    /// OS platform.\n    pub(crate) platform: String,\n    /// Client wariness for rollout throttling.\n    pub(crate) rollout_wariness: Option<NotNan<f64>>,\n    /// Stream label.\n    pub(crate) stream: String,\n}\n\nimpl Identity {\n    /// Create from configuration.\n    #[context(\"failed to validate agent identity configuration\")]\n    pub(crate) fn with_config(cfg: inputs::IdentityInput) -> Result<Self> {\n        let mut id = Self::try_default().context(\"failed to build default identity\")?;\n\n        if !cfg.group.is_empty() {\n            id.group = cfg.group;\n        };\n        id.validate_group_label()?;\n\n        if !cfg.node_uuid.is_empty() {\n            id.node_uuid = id128::Id128::parse_str(&cfg.node_uuid)\n                .map_err(|e| anyhow!(\"failed to parse node UUID: {}\", e))?;\n        }\n\n        if let Some(rw) = cfg.rollout_wariness {\n            ensure!(*rw >= 0.0, \"unexpected negative rollout wariness: {}\", rw);\n            ensure!(*rw <= 1.0, \"unexpected overlarge rollout wariness: {}\", rw);\n\n            prometheus::register(Box::new(ROLLOUT_WARINESS.clone()))?;\n            ROLLOUT_WARINESS.set(*rw);\n            id.rollout_wariness = Some(rw);\n        }\n\n        // Export info-metrics with details about booted deployment.\n        OS_INFO\n            .with_label_values(&[\n                &id.current_os.version,\n                &id.basearch,\n                &id.stream,\n                &id.platform,\n            ])\n            .set(1);\n\n        Ok(id)\n    }\n\n    /// Try to build default agent identity.\n    pub fn try_default() -> Result<Self> {\n        // Invoke rpm-ostree to get the status of the currently booted deployment.\n        let status = rpm_ostree::invoke_cli_status(true)?;\n        let basearch = coreos_stream_metadata::this_architecture().to_string();\n        let current_os =\n            rpm_ostree::parse_booted(&status).context(\"failed to introspect booted OS image\")?;\n        let node_uuid = {\n            let app_id = id128::Id128::try_from_slice(APP_ID)\n                .map_err(|e| anyhow!(\"failed to parse application ID: {}\", e))?;\n            compute_node_uuid(&app_id)?\n        };\n        let platform = platform::read_id(\"/proc/cmdline\")?;\n        let stream = rpm_ostree::parse_booted_updates_stream(&status)\n            .context(\"failed to introspect OS updates stream\")?;\n\n        let id = Self {\n            basearch,\n            stream,\n            platform,\n            current_os,\n            group: DEFAULT_GROUP.to_string(),\n            node_uuid,\n            rollout_wariness: None,\n        };\n        Ok(id)\n    }\n\n    /// Return context variables for URL templates.\n    pub fn url_variables(&self) -> HashMap<String, String> {\n        // This explicitly does not include \"current_version\"\n        // and \"node_uuid\".\n        let mut vars = HashMap::new();\n        vars.insert(\"basearch\".to_string(), self.basearch.clone());\n        vars.insert(\"group\".to_string(), self.group.clone());\n        vars.insert(\"platform\".to_string(), self.platform.clone());\n        vars.insert(\"stream\".to_string(), self.stream.clone());\n        vars\n    }\n\n    /// Return Cincinnati client parameters.\n    pub fn cincinnati_params(&self) -> HashMap<String, String> {\n        let mut vars = HashMap::new();\n        vars.insert(\"basearch\".to_string(), self.basearch.clone());\n        vars.insert(\"os_version\".to_string(), self.current_os.version.clone());\n        vars.insert(\"group\".to_string(), self.group.clone());\n        vars.insert(\"node_uuid\".to_string(), self.node_uuid.lower_hex());\n        vars.insert(\"platform\".to_string(), self.platform.clone());\n        vars.insert(\"stream\".to_string(), self.stream.clone());\n        vars.insert(\"os_checksum\".to_string(), self.current_os.payload.whole());\n        vars.insert(\"oci\".to_string(), \"true\".to_string());\n        if let Some(rw) = self.rollout_wariness {\n            vars.insert(\"rollout_wariness\".to_string(), format!(\"{:.06}\", rw));\n        }\n        vars\n    }\n\n    #[cfg(test)]\n    pub(crate) fn mock_default() -> Self {\n        use rpm_ostree::Payload;\n        Self {\n            basearch: \"mock-amd64\".to_string(),\n            current_os: rpm_ostree::Release {\n                version: \"0.0.0-mock\".to_string(),\n                payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n                age_index: None,\n            },\n            group: \"mock-workers\".to_string(),\n            node_uuid: id128::Id128::parse_str(\"e0f3745b108f471cbd4883c6fbed8cdd\").unwrap(),\n            platform: \"mock-azure\".to_string(),\n            rollout_wariness: Some(NotNan::new(0.5).unwrap()),\n            stream: \"mock-stable\".to_string(),\n        }\n    }\n\n    /// Validate the group label value in current identity.\n    ///\n    /// Group setting can be transmitted to external backends (Cincinnati and FleetLock).\n    /// This ensures that label value is compliant to specs regex:\n    ///  - https://coreos.github.io/zincati/development/fleetlock/protocol/#body\n    fn validate_group_label(&self) -> Result<()> {\n        static VALID_GROUP: &str = \"^[a-zA-Z0-9.-]+$\";\n        lazy_static! {\n            static ref VALID_GROUP_REGEX: Regex = Regex::new(VALID_GROUP).unwrap();\n        }\n        if !VALID_GROUP_REGEX.is_match(&self.group) {\n            anyhow::bail!(\n                \"invalid group label '{}': not conforming to expression '{}'\",\n                self.group,\n                VALID_GROUP\n            );\n        }\n        Ok(())\n    }\n}\n\nfn compute_node_uuid(app_id: &id128::Id128) -> Result<id128::Id128> {\n    let id = id128::get_machine_app_specific(app_id)\n        .map_err(|e| anyhow!(\"failed to get node ID: {}\", e))?;\n    Ok(id)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn identity_url_variables() {\n        let id = Identity::mock_default();\n        let vars = id.url_variables();\n\n        assert!(vars.contains_key(\"basearch\"));\n        assert!(vars.contains_key(\"group\"));\n        assert!(vars.contains_key(\"platform\"));\n        assert!(vars.contains_key(\"stream\"));\n        assert!(!vars.contains_key(\"node_uuid\"));\n        assert!(!vars.contains_key(\"os_checksum\"));\n        assert!(!vars.contains_key(\"os_version\"));\n    }\n\n    #[test]\n    fn identity_cincinnati_params() {\n        let mut id = Identity::mock_default();\n        id.validate_group_label().unwrap();\n\n        {\n            let valid = vec![\n                \"default\",\n                \"worker\",\n                \"01\",\n                \"group-A\",\n                \"infra.01\",\n                \"example.com\",\n            ];\n            for entry in valid {\n                id.group = entry.to_string();\n                id.validate_group_label().unwrap();\n            }\n        }\n\n        {\n            let invalid = vec![\"\", \"intránët\"];\n            for entry in invalid {\n                id.group = entry.to_string();\n                id.validate_group_label().unwrap_err();\n            }\n        }\n    }\n\n    #[test]\n    fn identity_validate_group() {\n        let id = Identity::mock_default();\n        let vars = id.cincinnati_params();\n\n        assert!(vars.contains_key(\"basearch\"));\n        assert!(vars.contains_key(\"group\"));\n        assert!(vars.contains_key(\"platform\"));\n        assert!(vars.contains_key(\"stream\"));\n        assert!(vars.contains_key(\"node_uuid\"));\n        assert!(vars.contains_key(\"os_checksum\"));\n        assert!(vars.contains_key(\"os_version\"));\n    }\n}\n"
  },
  {
    "path": "src/identity/platform.rs",
    "content": "//! Kernel cmdline parsing - utility functions\n//!\n//! NOTE(lucab): this is not a complete/correct cmdline parser, as it implements\n//!  just enough logic to extract the platform ID value. In particular, it does not\n//!  handle separator quoting/escaping, list of values, and merging of repeated\n//!  flags. Logic is taken from Afterburn, please backport any bugfix there too:\n//!  https://github.com/coreos/afterburn/blob/v4.1.0/src/util/cmdline.rs\n\nuse anyhow::{Context, Result};\n\n/// Platform key.\nstatic CMDLINE_PLATFORM_FLAG: &str = \"ignition.platform.id\";\n\n/// Read platform value from cmdline file.\npub(crate) fn read_id<T>(cmdline_path: T) -> Result<String>\nwhere\n    T: AsRef<str>,\n{\n    let fpath = cmdline_path.as_ref();\n    let contents = std::fs::read_to_string(fpath)\n        .with_context(|| format!(\"failed to read cmdline file {}\", fpath))?;\n\n    // lookup flag by key name\n    match find_flag_value(CMDLINE_PLATFORM_FLAG, &contents) {\n        Some(platform) => {\n            log::trace!(\"found platform id: {}\", platform);\n            Ok(platform)\n        }\n        None => anyhow::bail!(\n            \"could not find flag '{}' in {}\",\n            CMDLINE_PLATFORM_FLAG,\n            fpath\n        ),\n    }\n}\n\n/// Find OEM ID flag value in cmdline string.\nfn find_flag_value(flagname: &str, cmdline: &str) -> Option<String> {\n    // split content into elements and keep key-value tuples only.\n    let params: Vec<(&str, &str)> = cmdline\n        .split(' ')\n        .filter_map(|s| {\n            let kv: Vec<&str> = s.splitn(2, '=').collect();\n            match kv.len() {\n                2 => Some((kv[0], kv[1])),\n                _ => None,\n            }\n        })\n        .collect();\n\n    // find the oem flag\n    for (key, val) in params {\n        if key != flagname {\n            continue;\n        }\n        let bare_val = val.trim();\n        if !bare_val.is_empty() {\n            return Some(bare_val.to_string());\n        }\n    }\n    None\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    #[test]\n    fn test_find_flag() {\n        let flagname = \"ignition.platform.id\";\n        let tests = vec![\n            (\"\", None),\n            (\"foo=bar\", None),\n            (\"ignition.platform.id\", None),\n            (\"ignition.platform.id=\", None),\n            (\"ignition.platform.id=\\t\", None),\n            (\"ignition.platform.id=ec2\", Some(\"ec2\".to_string())),\n            (\"ignition.platform.id=\\tec2\", Some(\"ec2\".to_string())),\n            (\"ignition.platform.id=ec2\\n\", Some(\"ec2\".to_string())),\n            (\"foo=bar ignition.platform.id=ec2\", Some(\"ec2\".to_string())),\n            (\"ignition.platform.id=ec2 foo=bar\", Some(\"ec2\".to_string())),\n        ];\n        for (tcase, tres) in tests {\n            let res = find_flag_value(flagname, tcase);\n            assert_eq!(res, tres, \"failed testcase: '{}'\", tcase);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "//! Agent for Fedora CoreOS auto-updates.\n\n#![deny(missing_debug_implementations)]\n#![deny(missing_docs)]\n\n#[macro_use(fail_point)]\nextern crate fail;\n#[macro_use]\nextern crate prometheus;\n\n// Cincinnati client.\nmod cincinnati;\nmod cli;\n/// File-based configuration.\nmod config;\n/// D-Bus service.\nmod dbus;\n/// FleetLock client.\nmod fleet_lock;\n/// Agent identity.\nmod identity;\n/// Metrics service.\nmod metrics;\n/// rpm-ostree client.\nmod rpm_ostree;\n/// Update strategies.\nmod strategy;\n/// Update agent.\nmod update_agent;\n/// Utility functions.\nmod utils;\n/// Logic for weekly maintenance windows.\nmod weekly;\n\nuse clap::{crate_name, Parser};\n\n/// Binary entrypoint, for all CLI subcommands.\nfn main() {\n    let exit_code = run();\n    std::process::exit(exit_code);\n}\n\n/// Run till completion or failure, pretty-printing termination errors if any.\nfn run() -> i32 {\n    // Parse command-line options.\n    let cli_opts = cli::CliOptions::parse();\n\n    // Setup logging.\n    env_logger::Builder::from_default_env()\n        .format_timestamp(None)\n        .format_module_path(false)\n        .filter(Some(crate_name!()), cli_opts.loglevel())\n        .init();\n\n    // Dispatch CLI subcommand.\n    match cli_opts.run() {\n        Ok(_) => libc::EXIT_SUCCESS,\n        Err(e) => {\n            log_error_chain(&e);\n            if e.root_cause()\n                .downcast_ref::<crate::rpm_ostree::SystemInoperable>()\n                .is_some()\n            {\n                0\n            } else {\n                libc::EXIT_FAILURE\n            }\n        }\n    }\n}\n\n/// Pretty-print a chain of errors, as a series of error-priority log messages.\nfn log_error_chain(err_chain: &anyhow::Error) {\n    let mut chain_iter = err_chain.chain();\n    let top_err = match chain_iter.next() {\n        Some(e) => e.to_string(),\n        None => \"(unspecified failure)\".to_string(),\n    };\n    log::error!(\"error: {}\", top_err);\n    for err in chain_iter {\n        log::error!(\" -> {}\", err);\n    }\n}\n"
  },
  {
    "path": "src/metrics/mod.rs",
    "content": "//! Metrics endpoint over a Unix-domain socket.\n\nuse actix::prelude::*;\nuse anyhow::{bail, Context, Result};\nuse std::os::unix::net as std_net;\nuse std::path::Path;\nuse tokio::net as tokio_net;\n\n/// Unix socket path.\nstatic SOCKET_PATH: &str = \"/run/zincati/public/metrics.promsock\";\n\n/// Metrics exposition service.\n#[derive(Debug)]\npub struct MetricsService {\n    listener: std_net::UnixListener,\n}\n\nimpl MetricsService {\n    /// Create metrics service and bind to the Unix-domain socket.\n    pub fn bind_socket() -> Result<Self> {\n        Self::bind_socket_at(SOCKET_PATH)\n            .with_context(|| format!(\"failed to setup metrics service on '{}'\", SOCKET_PATH))\n    }\n\n    pub(crate) fn bind_socket_at(path: impl AsRef<Path>) -> Result<Self> {\n        if let Err(e) = std::fs::remove_file(path.as_ref()) {\n            if e.kind() != std::io::ErrorKind::NotFound {\n                bail!(\"failed to remove socket file: {}\", e);\n            }\n        };\n        let listener = std_net::UnixListener::bind(path.as_ref())\n            .context(\"failed to bind metrics service to Unix socket'\")?;\n        Ok(Self { listener })\n    }\n\n    /// Gather metrics from the default registry and encode them in textual format.\n    fn prometheus_text_encode() -> Result<Vec<u8>> {\n        use prometheus::Encoder;\n\n        let metric_families = prometheus::gather();\n        let encoder = prometheus::TextEncoder::new();\n        let mut buffer = Vec::new();\n        encoder.encode(&metric_families, &mut buffer)?;\n        Ok(buffer)\n    }\n}\n\n/// Incoming Unix-domain socket connection.\nstruct Connection {\n    stream: tokio_net::UnixStream,\n}\n\nimpl Message for Connection {\n    type Result = ();\n}\n\nimpl Actor for MetricsService {\n    type Context = actix::Context<Self>;\n\n    fn started(&mut self, ctx: &mut actix::Context<Self>) {\n        let listener = self\n            .listener\n            .try_clone()\n            .expect(\"failed to clone metrics listener\");\n        listener\n            .set_nonblocking(true)\n            .expect(\"failed to move metrics listener into nonblocking mode\");\n        let async_listener = tokio_net::UnixListener::from_std(listener)\n            .expect(\"failed to create async metrics listener\");\n\n        // This uses manual stream unfolding in order to keep the async listener\n        // alive for the whole duration of the stream.\n        let connections = futures::stream::unfold(async_listener, |l| async move {\n            loop {\n                let next = l.accept().await;\n                if let Ok((stream, _addr)) = next {\n                    let conn = Connection { stream };\n                    break Some((conn, l));\n                }\n            }\n        });\n\n        ctx.add_stream(connections);\n\n        log::debug!(\n            \"started metrics service on Unix-domain socket '{}'\",\n            SOCKET_PATH\n        );\n    }\n}\n\nimpl actix::io::WriteHandler<std::io::Error> for MetricsService {\n    fn error(&mut self, _err: std::io::Error, _ctx: &mut Self::Context) -> Running {\n        actix::Running::Continue\n    }\n\n    fn finished(&mut self, _ctx: &mut Self::Context) {}\n}\n\nimpl StreamHandler<Connection> for MetricsService {\n    fn handle(&mut self, item: Connection, ctx: &mut actix::Context<MetricsService>) {\n        let mut wr = actix::io::Writer::new(item.stream, ctx);\n        if let Ok(metrics) = MetricsService::prometheus_text_encode() {\n            wr.write(&metrics);\n        }\n        wr.close();\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_bind_socket_at() {\n        // Error path (EPERM or EISDIR).\n        MetricsService::bind_socket_at(\"/proc\").unwrap_err();\n\n        let tmpdir = tempfile::tempdir().unwrap();\n        let tmp_socket_path = tmpdir.path().join(\"test-socket\");\n        // Create a socket file and leave it behind on disk.\n        let service = MetricsService::bind_socket_at(&tmp_socket_path).unwrap();\n        drop(service);\n        // Make sure that the next run can remove it and start normally.\n        let service = MetricsService::bind_socket_at(&tmp_socket_path).unwrap();\n        drop(service);\n    }\n}\n"
  },
  {
    "path": "src/rpm_ostree/actor.rs",
    "content": "//! rpm-ostree client actor.\n\nuse super::cli_status::Status;\nuse super::Release;\nuse actix::prelude::*;\nuse anyhow::{Context, Result};\nuse filetime::FileTime;\nuse log::trace;\nuse ostree_ext::container::OstreeImageReference;\nuse ostree_ext::oci_spec::distribution::Reference;\nuse std::collections::BTreeSet;\nuse std::rc::Rc;\n\n/// Cache of local deployments.\n#[derive(Clone, Debug)]\npub struct StatusCache {\n    pub status: Rc<Status>,\n    pub mtime: FileTime,\n}\n\n/// Client actor for rpm-ostree.\n#[derive(Debug, Default, Clone)]\npub struct RpmOstreeClient {\n    // NB: This is OK for now because `rpm-ostree` actor is curently spawned on a single thread,\n    // but if we move to a larger threadpool, each actor thread will have its own cache.\n    pub status_cache: Option<StatusCache>,\n}\n\nimpl Actor for RpmOstreeClient {\n    type Context = SyncContext<Self>;\n}\n\nimpl RpmOstreeClient {\n    /// Start the threadpool for rpm-ostree blocking clients.\n    pub fn start(threads: usize) -> Addr<Self> {\n        SyncArbiter::start(threads, RpmOstreeClient::default)\n    }\n}\n\n/// Request: stage a deployment (in finalization-locked mode).\n#[derive(Debug, Clone)]\npub struct StageDeployment {\n    /// Whether to allow downgrades.\n    pub allow_downgrade: bool,\n    /// Release to be staged.\n    pub release: Release,\n}\n\nimpl Message for StageDeployment {\n    type Result = Result<Release>;\n}\n\nimpl Handler<StageDeployment> for RpmOstreeClient {\n    type Result = Result<Release>;\n\n    fn handle(&mut self, msg: StageDeployment, _ctx: &mut Self::Context) -> Self::Result {\n        let rebase_target = {\n            // If there is staged deployment we use that to determine if we should rebase or deploy\n            // Otherwise, fallback to booted.\n            // This is because if we already staged a rebase, rebasing again won't work\n            // as \"Old and new refs are equal\"\n            // see https://github.com/coreos/szincati/pull/1273#issuecomment-2721531804\n            let status = super::cli_status::invoke_cli_status(false)?;\n            let local_deploy = match super::cli_status::get_staged_deployment(&status) {\n                Some(staged_deploy) => staged_deploy,\n                None => super::cli_status::booted_status(&status)?,\n            };\n\n            if let Some(booted_imgref) = local_deploy.get_container_image_reference() {\n                let booted_oci_ref: Reference = booted_imgref.imgref.name.parse()?;\n                let stream = local_deploy.get_fcos_update_stream()?;\n\n                // The cinncinati payload contains the container image pullspec, pinned to a digest.\n                // There are two cases where we want to rebase to a OSTree OCI refspec.\n                // 1 - The image we are booted on does not match a stream tag, e.g. the node was manually\n                //     rebased to a version tag or a pinned digest. Here deploy would work but would lead\n                //     to a weird UX:\n                //     rpm-ostree status would show the version tag in the origin after we moved on to\n                //     another version.\n                // 2 - The image name we are following has changed (new registry, new name)\n                //     In that case `deploy` won't work and we need to rebase to the new refspec.\n\n                // The oci reference we want to end up with\n                let tagged_rebase_ref = Reference::with_tag(\n                    msg.release.payload.registry().to_string(),\n                    msg.release.payload.repository().to_string(),\n                    stream,\n                );\n\n                // if those don't match we need to rebase\n                if booted_oci_ref != tagged_rebase_ref {\n                    // craft a new ostree imgref object with the tagged oci reference we'll use for\n                    // the rebase command so rpm-ostree will verify the signature of the OSTree commit\n                    // wrapped inside the container:\n                    let rebase_target = OstreeImageReference {\n                        sigverify: booted_imgref.sigverify,\n                        imgref: ostree_ext::container::ImageReference {\n                            transport: booted_imgref.imgref.transport,\n                            name: tagged_rebase_ref.whole(),\n                        },\n                    };\n                    Some(rebase_target)\n                } else {\n                    None\n                }\n            } else {\n                // This should never happen as requesting the OCI graph only happens after we detected the local deployment is OCI.\n                // But let's fail gracefuly just in case.\n                anyhow::bail!(\"Zincati does not support OCI updates if the current deployment is not already an OCI image reference.\")\n            }\n        };\n        trace!(\"request to stage release: {:?}\", &msg.release);\n        let release =\n            super::cli_deploy::deploy_locked(msg.release, msg.allow_downgrade, rebase_target);\n        trace!(\"rpm-ostree CLI returned: {:?}\", release);\n        release\n    }\n}\n\n/// Request: finalize a staged deployment (by unlocking it and rebooting).\n#[derive(Debug, Clone)]\npub struct FinalizeDeployment {\n    /// Finalized release to finalize.\n    pub release: Release,\n}\n\nimpl Message for FinalizeDeployment {\n    type Result = Result<Release>;\n}\n\nimpl Handler<FinalizeDeployment> for RpmOstreeClient {\n    type Result = Result<Release>;\n\n    fn handle(&mut self, msg: FinalizeDeployment, _ctx: &mut Self::Context) -> Self::Result {\n        trace!(\"request to finalize release: {:?}\", msg.release);\n        let release = super::cli_finalize::finalize_deployment(msg.release);\n        trace!(\"rpm-ostree CLI returned: {:?}\", release);\n        release\n    }\n}\n\n/// Request: query local deployments.\n#[derive(Debug, Clone)]\npub struct QueryLocalDeployments {\n    /// Whether to include staged (i.e. not finalized) deployments in query result.\n    pub(crate) omit_staged: bool,\n}\n\nimpl Message for QueryLocalDeployments {\n    type Result = Result<BTreeSet<Release>>;\n}\n\nimpl Handler<QueryLocalDeployments> for RpmOstreeClient {\n    type Result = Result<BTreeSet<Release>>;\n\n    fn handle(\n        &mut self,\n        query_msg: QueryLocalDeployments,\n        _ctx: &mut Self::Context,\n    ) -> Self::Result {\n        trace!(\"request to list local deployments\");\n        let releases = super::cli_status::local_deployments(self, query_msg.omit_staged);\n        trace!(\"rpm-ostree CLI returned: {:?}\", releases);\n        releases\n    }\n}\n\n/// Request: query pending deployment and stream.\n#[derive(Debug, Clone)]\npub struct QueryPendingDeploymentStream {}\n\nimpl Message for QueryPendingDeploymentStream {\n    type Result = Result<Option<(Release, String)>>;\n}\n\nimpl Handler<QueryPendingDeploymentStream> for RpmOstreeClient {\n    type Result = Result<Option<(Release, String)>>;\n\n    fn handle(\n        &mut self,\n        _msg: QueryPendingDeploymentStream,\n        _ctx: &mut Self::Context,\n    ) -> Self::Result {\n        trace!(\"fetching details for staged deployment\");\n\n        let status = super::cli_status::invoke_cli_status(false)?;\n        super::cli_status::parse_pending_deployment(&status)\n            .context(\"failed to introspect pending deployment\")\n    }\n}\n\n/// Request: cleanup pending deployment.\n#[derive(Debug, Clone)]\npub struct CleanupPendingDeployment {}\n\nimpl Message for CleanupPendingDeployment {\n    type Result = Result<()>;\n}\n\nimpl Handler<CleanupPendingDeployment> for RpmOstreeClient {\n    type Result = Result<()>;\n\n    fn handle(&mut self, _msg: CleanupPendingDeployment, _ctx: &mut Self::Context) -> Self::Result {\n        trace!(\"request to cleanup pending deployment\");\n        super::cli_deploy::invoke_cli_cleanup()?;\n        Ok(())\n    }\n}\n\n/// Request: Register as the update driver for rpm-ostree.\n#[derive(Debug, Clone)]\npub struct RegisterAsDriver {}\n\nimpl Message for RegisterAsDriver {\n    type Result = ();\n}\n\nimpl Handler<RegisterAsDriver> for RpmOstreeClient {\n    type Result = ();\n\n    fn handle(&mut self, _msg: RegisterAsDriver, _ctx: &mut Self::Context) -> Self::Result {\n        trace!(\"request to register as rpm-ostree update driver\");\n        super::cli_deploy::deploy_register_driver()\n    }\n}\n"
  },
  {
    "path": "src/rpm_ostree/cli_deploy.rs",
    "content": "//! Interface to `rpm-ostree deploy --lock-finalization` and\n//! `rpm-ostree deploy --register-driver`.\n\nuse crate::rpm_ostree::Release;\nuse anyhow::{anyhow, bail, Context, Result};\nuse once_cell::sync::Lazy;\nuse ostree_ext::container::OstreeImageReference;\nuse prometheus::IntCounter;\nuse std::time::Duration;\n\nconst DRIVER_NAME: &str = \"Zincati\";\n\nstatic DEPLOY_ATTEMPTS: Lazy<IntCounter> = Lazy::new(|| {\n    register_int_counter!(opts!(\n        \"zincati_rpm_ostree_deploy_attempts_total\",\n        \"Total number of 'rpm-ostree deploy' attempts.\"\n    ))\n    .unwrap()\n});\nstatic DEPLOY_FAILURES: Lazy<IntCounter> = Lazy::new(|| {\n    register_int_counter!(opts!(\n        \"zincati_rpm_ostree_deploy_failures_total\",\n        \"Total number of 'rpm-ostree deploy' failures.\"\n    ))\n    .unwrap()\n});\nstatic REGISTER_DRIVER_FAILURES: Lazy<IntCounter> = Lazy::new(|| {\n    register_int_counter!(opts!(\n        \"zincati_rpm_ostree_register_driver_failures_total\",\n        \"Total number of failures to register as driver for rpm-ostree.\"\n    ))\n    .unwrap()\n});\n\n/// Deploy an upgrade (by checksum) and leave the new deployment locked.\npub fn deploy_locked(\n    release: Release,\n    allow_downgrade: bool,\n    rebase: Option<OstreeImageReference>,\n) -> Result<Release> {\n    DEPLOY_ATTEMPTS.inc();\n\n    let result = invoke_cli_deploy(release, allow_downgrade, rebase);\n    if result.is_err() {\n        DEPLOY_FAILURES.inc();\n    }\n\n    result\n}\n\n/// Register as the update driver.\n/// Keep attempting to register as driver for rpm-ostree, with exponential backoff\n/// capped at 256 seconds.\npub fn deploy_register_driver() {\n    let mut register_attempt = invoke_cli_register();\n    let mut retry_secs = Duration::from_secs(1);\n    while let Err(attempt) = register_attempt {\n        REGISTER_DRIVER_FAILURES.inc();\n        log::error!(\"{}\\nretrying in {:?}\", attempt, retry_secs,);\n        // Use `std::thread::sleep` because the rpm-ostree actor is spawned in a SyncArbiter.\n        std::thread::sleep(retry_secs);\n        register_attempt = invoke_cli_register();\n        if retry_secs < Duration::from_secs(256) {\n            retry_secs *= 2;\n        }\n    }\n}\n\n/// CLI executor for registering driver.\nfn invoke_cli_register() -> Result<()> {\n    // `fail_point`s cause registration to fail on first 3 tries when unit testing.\n    fail_point!(\n        \"register_driver_err\",\n        REGISTER_DRIVER_FAILURES.get() < 2,\n        |_| bail!(\"register_driver_err\")\n    );\n    fail_point!(\n        \"register_driver_ok\",\n        REGISTER_DRIVER_FAILURES.get() >= 3,\n        |_| Ok(())\n    );\n\n    let mut cmd = std::process::Command::new(\"rpm-ostree\");\n    cmd.arg(\"deploy\")\n        .arg(\"\")\n        .arg(format!(\"--register-driver={}\", DRIVER_NAME))\n        .env(\"RPMOSTREE_CLIENT_ID\", \"zincati\");\n\n    let out = cmd.output().context(\"failed to run 'rpm-ostree' binary\")?;\n\n    if !out.status.success() {\n        bail!(\n            \"rpm-ostree deploy --register-driver failed:\\n{}\",\n            String::from_utf8_lossy(&out.stderr)\n        );\n    }\n\n    Ok(())\n}\n\n/// CLI executor for deploying upgrades.\nfn invoke_cli_deploy(\n    release: Release,\n    allow_downgrade: bool,\n    rebase: Option<OstreeImageReference>,\n) -> Result<Release> {\n    fail_point!(\"deploy_locked_err\", |_| bail!(\"deploy_locked_err\"));\n    fail_point!(\"deploy_locked_ok\", |_| Ok(release.clone()));\n\n    let mut cmd = std::process::Command::new(\"rpm-ostree\");\n    if let Some(rebase_target) = rebase {\n        cmd.arg(\"rebase\").arg(\"--lock-finalization\");\n        let digest = release\n            .payload\n            .digest()\n            .ok_or_else(|| anyhow!(\"Missing digest in Cincinnati payload\"))?;\n        cmd.arg(rebase_target.to_string()).arg(digest);\n    } else {\n        cmd.arg(\"deploy\")\n            .arg(\"--lock-finalization\")\n            .arg(release.payload.digest().unwrap());\n    }\n    cmd.env(\"RPMOSTREE_CLIENT_ID\", \"zincati\");\n    if !allow_downgrade {\n        cmd.arg(\"--disallow-downgrade\");\n    }\n    log::trace!(\n        \"Requesting rpm ostree deploy with arguments: {:?}\",\n        cmd.get_args()\n    );\n\n    let out = cmd.output().context(\"failed to run 'rpm-ostree' binary\")?;\n\n    if !out.status.success() {\n        bail!(\n            \"rpm-ostree deploy failed:\\n{}\",\n            String::from_utf8_lossy(&out.stderr)\n        );\n    }\n    Ok(release)\n}\n\n/// CLI executor for cleaning up the pending deployment.\npub fn invoke_cli_cleanup() -> Result<()> {\n    let mut cmd = std::process::Command::new(\"rpm-ostree\");\n    cmd.arg(\"cleanup\").arg(\"-p\");\n    let out = cmd.output().context(\"failed to run 'rpm-ostree' binary\")?;\n    if !out.status.success() {\n        bail!(\n            \"rpm-ostree cleanup failed:\\n{}\",\n            String::from_utf8_lossy(&out.stderr)\n        )\n    };\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    #[allow(unused_imports)]\n    use super::*;\n\n    #[cfg(feature = \"failpoints\")]\n    #[test]\n    fn deploy_locked_err() {\n        use crate::rpm_ostree::Payload;\n        let _guard = fail::FailScenario::setup();\n        fail::cfg(\"deploy_locked_err\", \"return\").unwrap();\n\n        let release = Release {\n            version: \"foo\".to_string(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos\").unwrap(),\n            age_index: None,\n        };\n        let result = deploy_locked(release, false, None);\n        assert!(result.is_err());\n        assert!(DEPLOY_ATTEMPTS.get() >= 1);\n        assert!(DEPLOY_FAILURES.get() >= 1);\n    }\n\n    #[cfg(feature = \"failpoints\")]\n    #[test]\n    fn deploy_locked_ok() {\n        use crate::rpm_ostree::Payload;\n        let _guard = fail::FailScenario::setup();\n        fail::cfg(\"deploy_locked_ok\", \"return\").unwrap();\n\n        let release = Release {\n            version: \"foo\".to_string(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos\").unwrap(),\n            age_index: None,\n        };\n        let result = deploy_locked(release.clone(), false, None).unwrap();\n        assert_eq!(result, release);\n        assert!(DEPLOY_ATTEMPTS.get() >= 1);\n    }\n\n    #[cfg(feature = \"failpoints\")]\n    #[test]\n    fn register_driver_err() {\n        use std::time::SystemTime;\n\n        let _guard = fail::FailScenario::setup();\n        fail::cfg(\"register_driver_err\", \"return\").unwrap();\n        fail::cfg(\"register_driver_ok\", \"return\").unwrap();\n\n        let now = SystemTime::now();\n        // expect to take 1 + 2 + 4 = 7 seconds\n        // to register as driver due to `fail_point`s\n        deploy_register_driver();\n        let elapsed = now.elapsed().unwrap().as_secs();\n        // `fail_point`s are set to succeed on 4th try\n        assert!(REGISTER_DRIVER_FAILURES.get() == 3);\n        assert!(elapsed >= 7);\n    }\n}\n"
  },
  {
    "path": "src/rpm_ostree/cli_finalize.rs",
    "content": "//! Interface to `rpm-ostree finalize-deployment`.\n\nuse super::Release;\nuse anyhow::{anyhow, bail, Context, Result};\nuse prometheus::IntCounter;\n\nlazy_static::lazy_static! {\n    static ref FINALIZE_ATTEMPTS: IntCounter = register_int_counter!(opts!(\n        \"zincati_rpm_ostree_finalize_attempts_total\",\n        \"Total number of 'rpm-ostree finalize-deployment' attempts.\"\n    )).unwrap();\n    static ref FINALIZE_FAILURES: IntCounter = register_int_counter!(opts!(\n        \"zincati_rpm_ostree_finalize_failures_total\",\n        \"Total number of 'rpm-ostree finalize-deployment' failures.\"\n    )).unwrap();\n}\n\n/// Unlock and finalize the new deployment.\npub fn finalize_deployment(release: Release) -> Result<Release> {\n    FINALIZE_ATTEMPTS.inc();\n    let mut cmd = std::process::Command::new(\"rpm-ostree\");\n    cmd.env(\"RPMOSTREE_CLIENT_ID\", \"zincati\")\n        .arg(\"finalize-deployment\");\n\n    let digest = release\n        .payload\n        .digest()\n        .ok_or_else(|| anyhow!(\"Missing digest in Cincinnati payload\"))?;\n    cmd.arg(digest);\n\n    let cmd_result = cmd.output().context(\"failed to run 'rpm-ostree' binary\")?;\n    if !cmd_result.status.success() {\n        FINALIZE_FAILURES.inc();\n        bail!(\n            \"rpm-ostree finalize-deployment failed:\\n{}\",\n            String::from_utf8_lossy(&cmd_result.stderr)\n        );\n    }\n\n    Ok(release)\n}\n"
  },
  {
    "path": "src/rpm_ostree/cli_status.rs",
    "content": "//! Interface to `rpm-ostree status --json`.\n\nuse super::actor::{RpmOstreeClient, StatusCache};\nuse super::Release;\nuse anyhow::{anyhow, bail, ensure, Context, Result};\nuse filetime::FileTime;\nuse log::{debug, trace};\nuse ostree_ext::container::OstreeImageReference;\nuse ostree_ext::oci_spec::distribution::Reference;\nuse prometheus::IntCounter;\nuse serde::Deserialize;\nuse std::collections::BTreeSet;\nuse std::fs;\nuse std::rc::Rc;\n\n/// Path to local OSTree deployments. We use its mtime to check for modifications (e.g. new deployments)\n/// to local deployments that might warrant querying `rpm-ostree status` again to update our knowledge\n/// of the current state of deployments.\nconst OSTREE_DEPLS_PATH: &str = \"/ostree/deploy\";\n\n/// Path to the fake deployment to use when migrating to OCI transport.\n/// Using this fake deployment instead of the booted one, zincati\n/// will get the next update from the OCI graph and rebase to the OCI image.\n/// See https://github.com/coreos/fedora-coreos-tracker/issues/1823\nstatic BOOTED_STATUS_OVERRIDE_FILE: &str = \"/run/zincati/booted-status-override.json\";\n\nlazy_static::lazy_static! {\n    static ref STATUS_CACHE_ATTEMPTS: IntCounter = register_int_counter!(opts!(\n        \"zincati_rpm_ostree_status_cache_requests_total\",\n        \"Total number of attempts to query rpm-ostree actor's cached status.\"\n    )).unwrap();\n    static ref STATUS_CACHE_MISSES: IntCounter = register_int_counter!(opts!(\n        \"zincati_rpm_ostree_status_cache_misses_total\",\n        \"Total number of times rpm-ostree actor's cached status is stale during queries.\"\n    )).unwrap();\n    // This is not equivalent to `zincati_rpm_ostree_status_cache_misses_total` as there\n    // are cases where `rpm-ostree status` is called directly without checking the cache.\n    static ref RPM_OSTREE_STATUS_ATTEMPTS: IntCounter = register_int_counter!(opts!(\n        \"zincati_rpm_ostree_status_attempts_total\",\n        \"Total number of 'rpm-ostree status' attempts.\"\n    )).unwrap();\n    static ref RPM_OSTREE_STATUS_FAILURES: IntCounter = register_int_counter!(opts!(\n        \"zincati_rpm_ostree_status_failures_total\",\n        \"Total number of 'rpm-ostree status' failures.\"\n    )).unwrap();\n}\n\n/// An error which should not result in a retry/restart.\n#[derive(Debug, Clone)]\npub struct SystemInoperable(String);\n\nimpl std::fmt::Display for SystemInoperable {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        self.0.fmt(f)\n    }\n}\n\nimpl std::error::Error for SystemInoperable {}\n\n/// JSON output from `rpm-ostree status --json`\n#[derive(Clone, Debug, Deserialize)]\npub struct Status {\n    deployments: Vec<Deployment>,\n}\n\n/// Partial deployment object (only fields relevant to zincati).\n#[derive(Clone, Debug, Deserialize)]\n#[serde(rename_all = \"kebab-case\")]\npub struct Deployment {\n    booted: bool,\n    container_image_reference: Option<String>,\n    container_image_reference_digest: Option<String>,\n    base_checksum: Option<String>,\n    base_commit_meta: BaseCommitMeta,\n    checksum: String,\n    // NOTE(lucab): missing field means \"not staged\".\n    #[serde(default)]\n    staged: bool,\n    version: String,\n}\n\n#[derive(Clone, Debug, Deserialize)]\nstruct BaseCommitMeta {\n    #[serde(rename = \"ostree.manifest\")]\n    oci_manifest: Option<String>,\n    #[serde(rename = \"ostree.container.image-config\")]\n    oci_image_configuration: Option<String>,\n}\n\nimpl Deployment {\n    /// Convert into `Release`.\n    pub fn into_release(self) -> Release {\n        let payload = self.get_container_image_reference_digest().expect(\n            \"Failed to find OCI image reference. \\n\\\n            Zincati only support bootable OCI containers and not ostree remotes.\",\n        );\n        Release {\n            payload,\n            version: self.version,\n            age_index: None,\n        }\n    }\n\n    /// Return the deployment base revision.\n    pub fn base_revision(&self) -> String {\n        self.container_image_reference\n            .clone()\n            .or(self.base_checksum.clone())\n            .unwrap_or_else(|| self.checksum.clone())\n    }\n\n    /// return the deployed container image reference\n    /// e.g. ostree-remote-image:fedora:registry:quay.io/fedora/fedora-coreos:stable\n    pub fn get_container_image_reference(&self) -> Option<OstreeImageReference> {\n        self.container_image_reference\n            .as_ref()\n            .and_then(|s| s.as_str().try_into().ok())\n    }\n\n    /// Return the deployed container image as an oci image reference\n    /// but with the digest instead of the tag\n    /// e.g. quay.io/fedora/fedora-coreos@sha256:c4a15145a232d882ccf2ed32d22c06c01a7cf62317eb966a98340ae4bd56dfa6\n    pub fn get_container_image_reference_digest(&self) -> Option<Reference> {\n        match (\n            &self.get_container_image_reference(),\n            &self.container_image_reference_digest,\n        ) {\n            (Some(imgref), Some(digest)) => {\n                let oci_ref: Option<Reference> = imgref.imgref.name.parse().ok();\n                oci_ref.map(|reference| reference.clone_with_digest(digest.clone()))\n            }\n            _ => None,\n        }\n    }\n\n    /// return the fedora-coreos update stream\n    pub fn get_fcos_update_stream(&self) -> Result<String> {\n        fedora_coreos_stream_from_deployment(self)\n    }\n}\n\n/// Parse the booted deployment from status object.\npub fn parse_booted(status: &Status) -> Result<Release> {\n    let status = booted_status(status)?;\n    Ok(status.into_release())\n}\n\nfn fedora_coreos_stream_from_deployment(deploy: &Deployment) -> Result<String> {\n    if deploy.base_commit_meta.oci_image_configuration.is_none()\n        && deploy.base_commit_meta.oci_manifest.is_none()\n    {\n        bail!(\"Cannot deserialize ostree base image manifest\");\n    }\n\n    // Check for `com.coreos.stream` label in OCI ImageConfiguration\n    if let Some(oci_image_configuration) = deploy.base_commit_meta.oci_image_configuration.as_ref()\n    {\n        let image_configuration: ostree_ext::oci_spec::image::ImageConfiguration =\n            serde_json::from_str(oci_image_configuration.as_str())?;\n        if let Some(stream) = image_configuration.config().as_ref().and_then(|cfg| {\n            cfg.labels()\n                .as_ref()\n                .and_then(|labels| labels.get(\"com.coreos.stream\"))\n        }) {\n            ensure!(!stream.is_empty(), \"empty stream value\");\n            debug!(\"Detected stream '{}' from com.coreos.stream label\", stream);\n            return Ok(stream.clone());\n        }\n    }\n\n    // Fallback to `fedora-coreos.stream` annotation in OCI ImageManifest\n    if let Some(oci_manifest) = deploy.base_commit_meta.oci_manifest.as_ref() {\n        let manifest: ostree_ext::oci_spec::image::ImageManifest =\n            serde_json::from_str(oci_manifest.as_str())?;\n        if let Some(stream) = manifest\n            .annotations()\n            .as_ref()\n            .and_then(|a| a.get(\"fedora-coreos.stream\"))\n        {\n            ensure!(!stream.is_empty(), \"empty stream value\");\n            debug!(\n                \"Detected stream '{}' from fedora-coreos.stream annotation\",\n                stream\n            );\n            return Ok(stream.clone());\n        }\n    }\n\n    Err(anyhow!(\n        \"Missing `com.coreos.stream` label in the base image configuration,\n        or `fedora-coreos.stream` annotation in the base image manifest\"\n    ))\n}\n\n/// Parse updates stream for booted deployment from status object.\npub fn parse_booted_updates_stream(status: &Status) -> Result<String> {\n    let json = booted_status(status)?;\n    fedora_coreos_stream_from_deployment(&json)\n}\n\n/// Parse pending deployment from status object.\npub fn parse_pending_deployment(status: &Status) -> Result<Option<(Release, String)>> {\n    // There can be at most one staged/pending rpm-ostree deployment,\n    // thus we only consider the first matching entry (if any).\n    let staged = get_staged_deployment(status);\n\n    match staged {\n        None => Ok(None),\n        Some(json) => {\n            let stream = fedora_coreos_stream_from_deployment(&json)?;\n            let release = json.into_release();\n            Ok(Some((release, stream)))\n        }\n    }\n}\n\n/// Return the pending/staged deployment\npub fn get_staged_deployment(status: &Status) -> Option<Deployment> {\n    // There can be at most one staged/pending rpm-ostree deployment,\n    // thus we only consider the first matching entry (if any).\n    status.deployments.iter().find(|d| d.staged).cloned()\n}\n\n/// Parse local deployments from a status object.\nfn parse_local_deployments(status: &Status, omit_staged: bool) -> BTreeSet<Release> {\n    let mut deployments = BTreeSet::<Release>::new();\n    for entry in &status.deployments {\n        if omit_staged && entry.staged {\n            continue;\n        }\n\n        let release = entry.clone().into_release();\n        deployments.insert(release);\n    }\n    deployments\n}\n\n/// Return local deployments, using client's cache if possible.\npub fn local_deployments(\n    client: &mut RpmOstreeClient,\n    omit_staged: bool,\n) -> Result<BTreeSet<Release>> {\n    let status = get_status(client)?;\n    let local_depls = parse_local_deployments(&status, omit_staged);\n\n    Ok(local_depls)\n}\n\n/// Return JSON object for booted deployment.\npub fn booted_status(status: &Status) -> Result<Deployment> {\n    let booted = status\n        .clone()\n        .deployments\n        .into_iter()\n        .find(|d| d.booted)\n        .ok_or_else(|| anyhow!(\"no booted deployment found\"))?;\n\n    ensure!(!booted.base_revision().is_empty(), \"empty base revision\");\n    ensure!(!booted.version.is_empty(), \"empty version\");\n    Ok(booted)\n}\n\n/// Ensure our status cache is up to date; if empty or out of date, run `rpm-ostree status` to populate it.\nfn get_status(client: &mut RpmOstreeClient) -> Result<Rc<Status>> {\n    STATUS_CACHE_ATTEMPTS.inc();\n    let ostree_depls_data = fs::metadata(OSTREE_DEPLS_PATH)\n        .with_context(|| format!(\"failed to query directory {}\", OSTREE_DEPLS_PATH))?;\n    let ostree_depls_data_mtime = FileTime::from_last_modification_time(&ostree_depls_data);\n\n    if let Some(cache) = &client.status_cache {\n        if cache.mtime == ostree_depls_data_mtime {\n            trace!(\"status cache is up to date\");\n            return Ok(cache.status.clone());\n        }\n    }\n\n    STATUS_CACHE_MISSES.inc();\n    trace!(\"cache stale, invoking rpm-ostree to retrieve local deployments\");\n    let status = Rc::new(invoke_cli_status(false)?);\n    client.status_cache = Some(StatusCache {\n        status: Rc::clone(&status),\n        mtime: ostree_depls_data_mtime,\n    });\n\n    Ok(status)\n}\n\n/// CLI executor for `rpm-ostree status --json`.\npub fn invoke_cli_status(booted_only: bool) -> Result<Status> {\n    RPM_OSTREE_STATUS_ATTEMPTS.inc();\n\n    let mut cmd = std::process::Command::new(\"rpm-ostree\");\n    cmd.arg(\"status\").env(\"RPMOSTREE_CLIENT_ID\", \"zincati\");\n\n    // Try to request the minimum scope we need.\n    if booted_only {\n        cmd.arg(\"--booted\");\n    }\n\n    let cmdrun = cmd\n        .arg(\"--json\")\n        .output()\n        .context(\"failed to run 'rpm-ostree' binary\")?;\n\n    if !cmdrun.status.success() {\n        RPM_OSTREE_STATUS_FAILURES.inc();\n        anyhow::bail!(\n            \"rpm-ostree status failed:\\n{}\",\n            String::from_utf8_lossy(&cmdrun.stderr)\n        );\n    }\n    let mut status: Status = serde_json::from_slice(&cmdrun.stdout)?;\n\n    // if the oci_migration file exist we want to graft it into the\n    // output of rpm-ostree status.\n    // Replace the booted status with the content of the override file\n    let status_override_file = std::path::Path::new(BOOTED_STATUS_OVERRIDE_FILE);\n    if status_override_file.exists() {\n        let rdr = std::fs::File::open(status_override_file).map(std::io::BufReader::new)?;\n        let override_boot_depl: Deployment = serde_json::from_reader(rdr)?;\n\n        // Keep the actual status info and just replace the booted deployement.\n        // We need other deployements info to know if we rollbacked\n        // or if a deployment is staged.\n        status.deployments = status\n            .deployments\n            .into_iter()\n            .map(|d| {\n                if d.booted {\n                    override_boot_depl.clone()\n                } else {\n                    d\n                }\n            })\n            .collect();\n    }\n    Ok(status)\n}\n\n#[cfg(test)]\nmod tests {\n    use ostree_ext::container::SignatureSource;\n\n    use super::*;\n\n    fn mock_status(path: &str) -> Result<Status> {\n        let r = std::fs::File::open(path).map(std::io::BufReader::new)?;\n        Ok(serde_json::from_reader(r)?)\n    }\n\n    #[test]\n    fn mock_deployments() {\n        {\n            let status = mock_status(\"tests/fixtures/rpm-ostree-status.json\").unwrap();\n            let deployments = parse_local_deployments(&status, false);\n            assert_eq!(deployments.len(), 1);\n        }\n        {\n            let status = mock_status(\"tests/fixtures/rpm-ostree-staged.json\").unwrap();\n            let deployments = parse_local_deployments(&status, false);\n            assert_eq!(deployments.len(), 2);\n        }\n        {\n            let status = mock_status(\"tests/fixtures/rpm-ostree-staged.json\").unwrap();\n            let deployments = parse_local_deployments(&status, true);\n            assert_eq!(deployments.len(), 2);\n        }\n        {\n            let status = mock_status(\"tests/fixtures/rpm-ostree-status-annotation.json\").unwrap();\n            let deployments = parse_local_deployments(&status, false);\n            assert_eq!(deployments.len(), 1);\n        }\n    }\n\n    #[test]\n    fn mock_booted_updates_stream() {\n        {\n            let status = mock_status(\"tests/fixtures/rpm-ostree-status.json\").unwrap();\n            let booted = booted_status(&status).unwrap();\n            let stream = fedora_coreos_stream_from_deployment(&booted).unwrap();\n            assert_eq!(stream, \"stable\");\n        }\n        {\n            let status = mock_status(\"tests/fixtures/rpm-ostree-status-annotation.json\").unwrap();\n            let booted = booted_status(&status).unwrap();\n            let stream = fedora_coreos_stream_from_deployment(&booted).unwrap();\n            assert_eq!(stream, \"stable\");\n        }\n    }\n\n    #[test]\n    fn mock_booted_oci_deployment() {\n        let status = mock_status(\"tests/fixtures/rpm-ostree-status.json\").unwrap();\n        let booted = booted_status(&status).unwrap();\n        let stream = fedora_coreos_stream_from_deployment(&booted).unwrap();\n        assert_eq!(stream, \"stable\");\n        let img_ref = booted.get_container_image_reference();\n        assert!(img_ref.is_some());\n        let img_ref = img_ref.unwrap();\n        assert_eq!(img_ref.sigverify, SignatureSource::ContainerPolicy);\n        assert_eq!(\n            img_ref.imgref.name,\n            \"quay.io/fedora/fedora-coreos:stable\".to_string()\n        );\n        let imgref_with_digest = booted.get_container_image_reference_digest();\n        assert!(imgref_with_digest.is_some());\n        let imgref_with_digest = imgref_with_digest.unwrap();\n        assert_eq!(imgref_with_digest.to_string(), \"quay.io/fedora/fedora-coreos@sha256:ca99893c80a7b84dd84d4143bd27538207c2f38ab6647a58d9c8caa251f9a087\".to_string());\n    }\n}\n"
  },
  {
    "path": "src/rpm_ostree/mock_tests.rs",
    "content": "use crate::cincinnati::Cincinnati;\nuse crate::identity::Identity;\nuse mockito::{self, Matcher};\nuse std::collections::BTreeSet;\nuse tokio::runtime as rt;\n\n#[test]\nfn test_simple_graph() {\n    let mut server = mockito::Server::new();\n    let simple_graph = r#\"\n{\n  \"nodes\": [\n    {\n      \"version\": \"0.0.0-mock\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.scheme\": \"oci\",\n        \"org.fedoraproject.coreos.releases.age_index\": \"0\"\n      },\n      \"payload\": \"quay.io/fedora/fedora-coreos:oci-mock\"\n    },\n    {\n      \"version\": \"43.20251120.3.0\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.scheme\": \"oci\",\n        \"org.fedoraproject.coreos.releases.age_index\": \"1\"\n      },\n    \"payload\": \"quay.io/fedora/fedora-coreos:latest\"\n    }\n  ],\n  \"edges\": [\n    [\n      0,\n      1\n    ]\n  ]\n}\n\"#;\n\n    let m_graph = server\n        .mock(\"GET\", Matcher::Regex(r\"^/v1/graph?.+$\".to_string()))\n        .match_header(\"accept\", Matcher::Regex(\"application/json\".to_string()))\n        .with_body(simple_graph)\n        .with_status(200)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = Cincinnati {\n        base_url: server.url(),\n    };\n    let update = runtime.block_on(client.fetch_update_hint(&id, BTreeSet::new(), false));\n    m_graph.assert();\n\n    let next = update.unwrap();\n    assert_eq!(next.version, \"43.20251120.3.0\")\n}\n\n#[test]\nfn test_downgrade() {\n    let mut server = mockito::Server::new();\n    let simple_graph = r#\"\n{\n  \"nodes\": [\n    {\n      \"version\": \"43.20251120.3.0\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.scheme\": \"oci\",\n        \"org.fedoraproject.coreos.releases.age_index\": \"0\"\n      },\n    \"payload\": \"quay.io/fedora/fedora-coreos:downgrade\"\n    },\n    {\n      \"version\": \"0.0.0-mock\",\n      \"metadata\": {\n        \"org.fedoraproject.coreos.scheme\": \"oci\",\n        \"org.fedoraproject.coreos.releases.age_index\": \"1\"\n      },\n      \"payload\": \"quay.io/fedora/fedora-coreos:oci-mock\"\n    }\n  ],\n  \"edges\": [\n    [\n      1,\n      0\n    ]\n  ]\n}\n\"#;\n\n    let m_graph = server\n        .mock(\"GET\", Matcher::Regex(r\"^/v1/graph?.+$\".to_string()))\n        .match_header(\"accept\", Matcher::Regex(\"application/json\".to_string()))\n        .with_body(simple_graph)\n        .with_status(200)\n        .expect(2)\n        .create();\n\n    let runtime = rt::Runtime::new().unwrap();\n    let id = Identity::mock_default();\n    let client = Cincinnati {\n        base_url: server.url(),\n    };\n\n    // Downgrades denied.\n    let upgrade = runtime.block_on(client.fetch_update_hint(&id, BTreeSet::new(), false));\n    assert_eq!(upgrade, None);\n\n    // Downgrades allowed.\n    let downgrade = runtime.block_on(client.fetch_update_hint(&id, BTreeSet::new(), true));\n\n    m_graph.assert();\n    let next = downgrade.unwrap();\n    assert_eq!(next.version, \"43.20251120.3.0\")\n}\n"
  },
  {
    "path": "src/rpm_ostree/mod.rs",
    "content": "mod cli_deploy;\nmod cli_finalize;\nmod cli_status;\npub use cli_status::{\n    invoke_cli_status, parse_booted, parse_booted_updates_stream, SystemInoperable,\n};\n\nmod actor;\npub use actor::{\n    CleanupPendingDeployment, FinalizeDeployment, QueryLocalDeployments,\n    QueryPendingDeploymentStream, RegisterAsDriver, RpmOstreeClient, StageDeployment,\n};\nuse ostree_ext::oci_spec::distribution::Reference;\n\n#[cfg(test)]\nmod mock_tests;\n\nuse crate::cincinnati::{Node, AGE_INDEX_KEY, OCI_SCHEME, SCHEME_KEY};\nuse anyhow::{anyhow, bail, ensure, Context, Result};\nuse serde::Serialize;\nuse std::cmp::Ordering;\n\n/// An OS release, as described by the cincinnati graph.\n#[derive(Clone, Debug, PartialEq, Eq, Serialize)]\npub struct Release {\n    /// OS version.\n    pub version: String,\n    /// Image base checksum or OCI pullspec.\n    pub payload: Payload,\n    /// Release age (Cincinnati `age_index`).\n    pub age_index: Option<u64>,\n}\n\npub type Payload = Reference;\n\nimpl std::cmp::Ord for Release {\n    fn cmp(&self, other: &Self) -> Ordering {\n        // Order is primarily based on age-index coming from Cincinnati.\n        let self_age = self.age_index.unwrap_or(0);\n        let other_age = other.age_index.unwrap_or(0);\n        if self_age != other_age {\n            return self_age.cmp(&other_age);\n        }\n\n        // As a fallback in case of duplicate age-index values, this tries\n        // to disambiguate by picking an arbitrary lexicographic order.\n        if self.version != other.version {\n            return self.version.cmp(&other.version);\n        }\n\n        if self.payload != other.payload {\n            let self_payload = self.payload.to_string();\n            return self_payload.cmp(&other.payload.to_string());\n        }\n\n        Ordering::Equal\n    }\n}\n\nimpl std::cmp::PartialOrd for Release {\n    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {\n        Some(self.cmp(other))\n    }\n}\n\nimpl Release {\n    /// Builds a `Release` object from a Cincinnati node.\n    pub fn from_cincinnati(node: Node) -> Result<Self> {\n        ensure!(!node.version.is_empty(), \"empty version field\");\n        ensure!(!node.payload.is_empty(), \"empty payload field (checksum)\");\n        let scheme = node\n            .metadata\n            .get(SCHEME_KEY)\n            .ok_or_else(|| anyhow!(\"missing metadata key: {}\", SCHEME_KEY))?;\n\n        let payload = match scheme.as_str() {\n            OCI_SCHEME => node.payload.parse()?,\n            _ => bail!(\"unexpected payload scheme: {}\", scheme),\n        };\n\n        let age = {\n            let val = node\n                .metadata\n                .get(AGE_INDEX_KEY)\n                .ok_or_else(|| anyhow!(\"missing metadata key: {}\", AGE_INDEX_KEY))?;\n\n            val.parse::<u64>()\n                .context(format!(\"invalid age_index value: {}\", val))?\n        };\n\n        let rel = Self {\n            version: node.version,\n            payload,\n            age_index: Some(age),\n        };\n        Ok(rel)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use maplit::hashmap;\n\n    #[test]\n    fn release_from_cincinnati() {\n        let input = Node {\n            version: \"mock-version\".to_string(),\n            payload: \"mock-payload\".to_string(),\n            metadata: hashmap! {\n                SCHEME_KEY.to_string() => OCI_SCHEME.to_string(),\n                AGE_INDEX_KEY.to_string() => \"0\".to_string(),\n            },\n        };\n        Release::from_cincinnati(input).unwrap();\n    }\n\n    #[test]\n    fn invalid_node() {\n        let node1 = Node {\n            version: \"\".to_string(),\n            payload: \"mock-payload\".to_string(),\n            metadata: hashmap! {\n                SCHEME_KEY.to_string() => OCI_SCHEME.to_string(),\n            },\n        };\n        Release::from_cincinnati(node1).unwrap_err();\n\n        let node2 = Node {\n            version: \"mock-version\".to_string(),\n            payload: \"\".to_string(),\n            metadata: hashmap! {\n                SCHEME_KEY.to_string() => OCI_SCHEME.to_string(),\n            },\n        };\n        Release::from_cincinnati(node2).unwrap_err();\n\n        let node3 = Node {\n            version: \"mock-version\".to_string(),\n            payload: \"mock-payload\".to_string(),\n            metadata: hashmap! {\n                SCHEME_KEY.to_string() => OCI_SCHEME.to_string(),\n            },\n        };\n        Release::from_cincinnati(node3).unwrap_err();\n\n        let node4 = Node {\n            version: \"mock-version\".to_string(),\n            payload: \"mock-payload\".to_string(),\n            metadata: hashmap! {},\n        };\n        Release::from_cincinnati(node4).unwrap_err();\n    }\n\n    #[test]\n    #[allow(clippy::nonminimal_bool)]\n    fn release_cmp() {\n        {\n            let n0 = Release {\n                version: \"v0\".to_string(),\n                payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n                age_index: Some(0),\n            };\n            let n1 = Release {\n                version: \"v1\".to_string(),\n                payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:latest\").unwrap(),\n                age_index: Some(1),\n            };\n            assert!(n0 < n1);\n            assert!(n0 == n0);\n            assert!(!(n0 < n0));\n            assert!(!(n0 > n0));\n        }\n        {\n            let n0 = Release {\n                version: \"v0\".to_string(),\n                payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n                age_index: Some(0),\n            };\n            let n1 = Release {\n                version: \"v1\".to_string(),\n                payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:latest\").unwrap(),\n                age_index: Some(0),\n            };\n            assert!(n0 < n1);\n            assert!(!(n0 < n0));\n            assert!(!(n0 > n0));\n        }\n    }\n}\n"
  },
  {
    "path": "src/strategy/fleet_lock.rs",
    "content": "//! Strategy for fleet-wide coordinated updates (FleetLock protocol).\n\nuse crate::config::inputs;\nuse crate::fleet_lock::{Client, ClientBuilder};\nuse crate::identity::Identity;\nuse anyhow::{anyhow, Error, Result};\nuse futures::prelude::*;\nuse log::trace;\nuse prometheus::IntCounterVec;\nuse serde::Serialize;\nuse std::pin::Pin;\n\nlazy_static::lazy_static! {\n    static ref FLEET_LOCK_REQUESTS: IntCounterVec = register_int_counter_vec!(\n        \"zincati_strategy_fleet_lock_requests_total\",\n        \"Total number of requests to the FleetLock server.\",\n        &[\"api\"]\n    ).unwrap();\n    static ref FLEET_LOCK_ERRORS: IntCounterVec = register_int_counter_vec!(\n        \"zincati_strategy_fleet_lock_errors_total\",\n        \"Total number of errors while talking to the FleetLock server.\",\n        &[\"api\", \"kind\"]\n    ).unwrap();\n}\n\n/// Strategy for remote coordination.\n#[derive(Clone, Debug, Serialize)]\npub(crate) struct StrategyFleetLock {\n    /// Asynchronous client.\n    pub(crate) client: Client,\n}\n\nimpl StrategyFleetLock {\n    /// Strategy label/name.\n    pub const LABEL: &'static str = \"fleet_lock\";\n\n    /// Build a new FleetLock strategy.\n    pub fn new(cfg: inputs::UpdateInput, identity: &Identity) -> Result<Self> {\n        // Substitute templated key with agent runtime values.\n        let base_url = if envsubst::is_templated(&cfg.fleet_lock.base_url) {\n            let context = identity.url_variables();\n            envsubst::validate_vars(&context)?;\n            envsubst::substitute(cfg.fleet_lock.base_url, &context)?\n        } else {\n            cfg.fleet_lock.base_url\n        };\n\n        if base_url.is_empty() {\n            anyhow::bail!(\"empty fleet_lock base URL\");\n        }\n        log::info!(\"remote fleet_lock reboot manager: {}\", &base_url);\n\n        let builder = ClientBuilder::new(base_url, identity);\n        let client = builder.build()?;\n        let strategy = Self { client };\n        Ok(strategy)\n    }\n\n    /// Check if finalization is allowed.\n    pub(crate) fn can_finalize(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {\n        let api = \"pre-reboot\";\n        FLEET_LOCK_REQUESTS.with_label_values(&[api]).inc();\n        trace!(\"fleet_lock strategy, checking whether update can be finalized\");\n\n        let res = self.client.pre_reboot().map_err(move |e| {\n            FLEET_LOCK_ERRORS\n                .with_label_values(&[api, &e.error_kind()])\n                .inc();\n            anyhow!(\"lock-manager {} failure: {}\", api, e)\n        });\n        Box::pin(res)\n    }\n\n    /// Try to report steady state.\n    pub(crate) fn report_steady(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {\n        let api = \"steady-state\";\n        FLEET_LOCK_REQUESTS.with_label_values(&[api]).inc();\n        trace!(\"fleet_lock strategy, attempting to report steady\");\n\n        let res = self.client.steady_state().map_err(move |e| {\n            FLEET_LOCK_ERRORS\n                .with_label_values(&[api, &e.error_kind()])\n                .inc();\n            anyhow!(\"lock-manager {} failure: {}\", api, e)\n        });\n        Box::pin(res)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::inputs::{FleetLockInput, PeriodicInput, UpdateInput};\n    use crate::identity::Identity;\n\n    #[test]\n    fn test_url_simple() {\n        let id = Identity::mock_default();\n        let input = UpdateInput {\n            allow_downgrade: false,\n            enabled: true,\n            strategy: \"fleet_lock\".to_string(),\n            fleet_lock: FleetLockInput {\n                base_url: \"https://example.com\".to_string(),\n            },\n            periodic: PeriodicInput {\n                intervals: vec![],\n                time_zone: \"UTC\".to_string(),\n            },\n        };\n\n        let res = StrategyFleetLock::new(input, &id);\n        assert!(res.is_ok());\n    }\n\n    #[test]\n    fn test_empty_url() {\n        let id = Identity::mock_default();\n        let input = UpdateInput {\n            allow_downgrade: false,\n            enabled: true,\n            strategy: \"fleet_lock\".to_string(),\n            fleet_lock: FleetLockInput {\n                base_url: String::new(),\n            },\n            periodic: PeriodicInput {\n                intervals: vec![],\n                time_zone: \"localtime\".to_string(),\n            },\n        };\n\n        let res = StrategyFleetLock::new(input, &id);\n        assert!(res.is_err());\n    }\n}\n"
  },
  {
    "path": "src/strategy/immediate.rs",
    "content": "//! Strategy for immediate updates.\n\nuse anyhow::Error;\nuse futures::future;\nuse futures::prelude::*;\nuse log::trace;\nuse serde::Serialize;\nuse std::pin::Pin;\n\n/// Strategy for immediate updates.\n#[derive(Clone, Debug, Default, Serialize)]\npub(crate) struct StrategyImmediate {}\n\nimpl StrategyImmediate {\n    /// Strategy label/name.\n    pub const LABEL: &'static str = \"immediate\";\n\n    /// Check if finalization is allowed.\n    pub(crate) fn can_finalize(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {\n        trace!(\"immediate strategy, can finalize updates: {}\", true);\n\n        let res = future::ok(true);\n        Box::pin(res)\n    }\n\n    pub(crate) fn report_steady(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {\n        trace!(\"immediate strategy, report steady: {}\", true);\n\n        let immediate = future::ok(true);\n        Box::pin(immediate)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tokio::runtime as rt;\n\n    #[test]\n    fn report_steady() {\n        let default = StrategyImmediate::default();\n        let runtime = rt::Runtime::new().unwrap();\n        let steady = runtime.block_on(default.report_steady()).unwrap();\n        assert!(steady);\n    }\n\n    #[test]\n    fn can_finalize() {\n        let default = StrategyImmediate::default();\n        let runtime = rt::Runtime::new().unwrap();\n        let can_finalize = runtime.block_on(default.can_finalize()).unwrap();\n        assert!(can_finalize);\n    }\n}\n"
  },
  {
    "path": "src/strategy/mod.rs",
    "content": "//! Update and reboot strategies.\n\nuse crate::config::inputs;\nuse crate::identity::Identity;\nuse anyhow::Result;\nuse fn_error_context::context;\nuse futures::prelude::*;\nuse log::error;\nuse prometheus::{IntCounterVec, IntGauge, IntGaugeVec};\nuse serde::Serialize;\n\nmod fleet_lock;\npub(crate) use fleet_lock::StrategyFleetLock;\n\nmod immediate;\npub(crate) use immediate::StrategyImmediate;\n\nmod periodic;\npub(crate) use periodic::StrategyPeriodic;\n\n/// Label for allow responses from querying strategy's `can_finalize` function.\npub static CAN_FINALIZE_ALLOW_LABEL: &str = \"allow\";\n\n/// Label for deny responses from querying strategy's `can_finalize` function.\npub static CAN_FINALIZE_DENY_LABEL: &str = \"deny\";\n\n/// Label for error responses from querying strategy's `can_finalize` function.\npub static CAN_FINALIZE_ERROR_LABEL: &str = \"error\";\n\nlazy_static::lazy_static! {\n    static ref STRATEGY_MODE: IntGaugeVec = register_int_gauge_vec!(\n        \"zincati_updates_strategy_mode\",\n        \"Update strategy mode in use\",\n        &[\"strategy\"]\n    ).unwrap();\n\n    static ref PERIODIC_LENGTH: IntGauge = register_int_gauge!(\n        \"zincati_updates_strategy_periodic_schedule_length_minutes\",\n        \"Total length of the periodic strategy schedule in use\"\n    ).unwrap();\n\n    static ref FINALIZATION_STRATEGY_RESPONSES: IntCounterVec = register_int_counter_vec!(\n        \"zincati_updates_strategy_can_finalize_responses\",\n        \"Total number of responses from querying update strategy for finalization consent.\",\n        &[\"response\"]\n    ).unwrap();\n}\n\n#[derive(Clone, Debug, Serialize)]\npub(crate) enum UpdateStrategy {\n    FleetLock(StrategyFleetLock),\n    Immediate(StrategyImmediate),\n    Periodic(StrategyPeriodic),\n}\n\nimpl UpdateStrategy {\n    /// Try to parse config inputs into a valid strategy.\n    #[context(\"failed to validate update strategy configuration\")]\n    pub(crate) fn with_config(cfg: inputs::UpdateInput, identity: &Identity) -> Result<Self> {\n        let strategy_name = cfg.strategy.clone();\n        let strategy = match strategy_name.as_ref() {\n            StrategyFleetLock::LABEL => UpdateStrategy::new_fleet_lock(cfg, identity)?,\n            StrategyImmediate::LABEL => UpdateStrategy::new_immediate(),\n            StrategyPeriodic::LABEL => UpdateStrategy::new_periodic(cfg)?,\n            \"\" => UpdateStrategy::default(),\n            x => anyhow::bail!(\"unsupported strategy '{}'\", x),\n        };\n\n        Ok(strategy)\n    }\n\n    /// Record strategy details to metrics and logs.\n    pub(crate) fn record_details(&self) {\n        self.refresh_metrics();\n        log::info!(\"update strategy: {}\", self.human_description());\n    }\n\n    /// Refresh strategy-related metrics values.\n    pub(crate) fn refresh_metrics(&self) {\n        // Export info-metrics with details about current strategy.\n        STRATEGY_MODE\n            .with_label_values(&[self.configuration_label()])\n            .set(1);\n\n        if let UpdateStrategy::Periodic(p) = self {\n            let sched_length = p.schedule_length_minutes();\n            PERIODIC_LENGTH.set(sched_length as i64);\n        };\n    }\n\n    /// Return the configuration label/name for this update strategy.\n    ///\n    /// This can be used to match back an instantiated strategy to the mode label\n    /// from configuration.\n    fn configuration_label(&self) -> &'static str {\n        match self {\n            UpdateStrategy::FleetLock(_) => StrategyFleetLock::LABEL,\n            UpdateStrategy::Immediate(_) => StrategyImmediate::LABEL,\n            UpdateStrategy::Periodic(_) => StrategyPeriodic::LABEL,\n        }\n    }\n\n    /// Return the human description for this strategy.\n    pub(crate) fn human_description(&self) -> String {\n        match self {\n            UpdateStrategy::FleetLock(_) => self.configuration_label().to_string(),\n            UpdateStrategy::Immediate(_) => self.configuration_label().to_string(),\n            UpdateStrategy::Periodic(p) => {\n                format!(\"{}, {}\", self.configuration_label(), p.calendar_summary(),)\n            }\n        }\n    }\n\n    /// Check if finalization is allowed at this time.\n    pub(crate) fn can_finalize(&self) -> impl Future<Output = bool> {\n        let lock = match self {\n            UpdateStrategy::FleetLock(s) => s.can_finalize(),\n            UpdateStrategy::Immediate(s) => s.can_finalize(),\n            UpdateStrategy::Periodic(s) => s.can_finalize(),\n        };\n\n        async {\n            match lock.await {\n                Ok(can_finalize) => {\n                    if can_finalize {\n                        FINALIZATION_STRATEGY_RESPONSES\n                            .with_label_values(&[CAN_FINALIZE_ALLOW_LABEL])\n                            .inc();\n                    } else {\n                        FINALIZATION_STRATEGY_RESPONSES\n                            .with_label_values(&[CAN_FINALIZE_DENY_LABEL])\n                            .inc();\n                    }\n                    can_finalize\n                }\n                Err(e) => {\n                    FINALIZATION_STRATEGY_RESPONSES\n                        .with_label_values(&[CAN_FINALIZE_ERROR_LABEL])\n                        .inc();\n                    error!(\"{}\", e);\n                    false\n                }\n            }\n        }\n    }\n\n    /// Try to report and enter steady state.\n    pub(crate) fn report_steady(&self) -> impl Future<Output = bool> {\n        let unlock = match self {\n            UpdateStrategy::FleetLock(s) => s.report_steady(),\n            UpdateStrategy::Immediate(s) => s.report_steady(),\n            UpdateStrategy::Periodic(s) => s.report_steady(),\n        };\n\n        async {\n            unlock.await.unwrap_or_else(|e| {\n                error!(\"{}\", e);\n                false\n            })\n        }\n    }\n\n    /// Build a new \"immediate\" strategy.\n    fn new_immediate() -> Self {\n        let immediate = StrategyImmediate::default();\n        UpdateStrategy::Immediate(immediate)\n    }\n\n    /// Build a new \"fleet_lock\" strategy.\n    fn new_fleet_lock(cfg: inputs::UpdateInput, identity: &Identity) -> Result<Self> {\n        let fleet_lock = StrategyFleetLock::new(cfg, identity)?;\n        Ok(UpdateStrategy::FleetLock(fleet_lock))\n    }\n\n    /// Build a new \"periodic\" strategy.\n    fn new_periodic(cfg: inputs::UpdateInput) -> Result<Self> {\n        let periodic = StrategyPeriodic::new(cfg)?;\n        Ok(UpdateStrategy::Periodic(periodic))\n    }\n}\n\nimpl Default for UpdateStrategy {\n    fn default() -> Self {\n        let immediate = StrategyImmediate::default();\n        UpdateStrategy::Immediate(immediate)\n    }\n}\n"
  },
  {
    "path": "src/strategy/periodic.rs",
    "content": "//! Strategy for periodic (weekly) updates.\n\nuse crate::config::inputs;\nuse crate::weekly::{utils, WeeklyCalendar, WeeklyWindow};\nuse anyhow::{Context, Error, Result};\nuse chrono::{TimeZone, Utc};\nuse fn_error_context::context;\nuse futures::future;\nuse futures::prelude::*;\nuse log::trace;\nuse serde::Serialize;\nuse std::fs::read_link;\nuse std::path::Path;\nuse std::pin::Pin;\nuse std::time::Duration;\nuse tzfile::Tz;\n\n/// Strategy for periodic (weekly) updates.\n#[derive(Clone, Debug, Serialize)]\npub(crate) struct StrategyPeriodic {\n    /// Whitelisted time windows during which updates are allowed.\n    schedule: WeeklyCalendar,\n    /// Time zone in which time windows are defined in.\n    #[serde(skip_serializing)]\n    pub(crate) time_zone: Tz,\n    /// Time zone name.\n    tz_name: String,\n}\n\nimpl Default for StrategyPeriodic {\n    fn default() -> Self {\n        let utc = \"UTC\";\n        StrategyPeriodic {\n            schedule: WeeklyCalendar::default(),\n            time_zone: Tz::named(utc).unwrap(),\n            tz_name: utc.to_string(),\n        }\n    }\n}\n\nimpl StrategyPeriodic {\n    /// Strategy label/name.\n    pub const LABEL: &'static str = \"periodic\";\n\n    /// Build a new periodic strategy.\n    #[context(\"failed to parse periodic strategy\")]\n    pub fn new(cfg: inputs::UpdateInput) -> Result<Self> {\n        let (time_zone, tz_name) = Self::get_time_zone_info_from_cfg(&cfg.periodic)?;\n\n        let mut intervals = Vec::with_capacity(cfg.periodic.intervals.len());\n        for entry in cfg.periodic.intervals {\n            let weekday = utils::weekday_from_string(&entry.start_day)?;\n            let start = utils::time_from_string(&entry.start_time)?;\n            let length = Duration::from_secs(u64::from(entry.length_minutes).saturating_mul(60));\n            let windows = WeeklyWindow::parse_timespan(weekday, start.0, start.1, length)?;\n            intervals.extend(windows);\n        }\n\n        let calendar = WeeklyCalendar::new(intervals);\n        match calendar.length_minutes() {\n            0 => anyhow::bail!(\n                \"invalid or missing periodic updates configuration: weekly calendar length is zero\"\n            ),\n            n => log::trace!(\"periodic updates, weekly calendar length: {} minutes\", n),\n        };\n\n        let strategy = Self {\n            schedule: calendar,\n            time_zone,\n            tz_name,\n        };\n        Ok(strategy)\n    }\n\n    /// Getter function for `StrategyPeriodic`'s `tz_name` field.\n    pub fn tz_name(&self) -> &str {\n        self.tz_name.as_str()\n    }\n\n    /// Get the time zone from `periodic` strategy config, returning a `Tz` and its name\n    /// in a tuple.\n    #[context(\"failed to get time zone info from config\")]\n    fn get_time_zone_info_from_cfg(cfg: &inputs::PeriodicInput) -> Result<(Tz, String)> {\n        let tz;\n        let tz_name;\n        if &cfg.time_zone == \"localtime\" {\n            let local_time_path = Path::new(\"/etc/localtime\");\n            // Use `read_link()` instead of `exists()` because we only want to check for\n            // the existence of the `/etc/localtime` symlink, not whether it points to\n            // a valid file (`read_link()` returns an error if symlink doesn't exist).\n            if read_link(local_time_path).is_err() {\n                let utc = \"UTC\";\n                tz = Tz::named(utc)\n                    .with_context(|| format!(\"failed to parse time zone named: {}\", utc));\n                tz_name = utc.to_string();\n            } else {\n                // Until `tzfile::Tz` has some way of getting its name or unique identifier, do\n                // the parsing of `/etc/localtime` ourselves here so we can get a `tz_str` to cache.\n                let tz_path = local_time_path.canonicalize()?;\n                let tz_str = tz_path\n                    .strip_prefix(Path::new(\"/usr/share/zoneinfo\"))\n                    .context(\n                        \"`/etc/localtime` does not link to a location in `/usr/share/zoneinfo`\",\n                    )?\n                    .to_str()\n                    .unwrap_or_default();\n                tz = Tz::named(tz_str)\n                    .with_context(|| format!(\"failed to parse time zone named: {}\", tz_str));\n                tz_name = tz_str.to_string();\n            }\n        } else {\n            tz = Tz::named(&cfg.time_zone)\n                .with_context(|| format!(\"failed to parse time zone named: {}\", &cfg.time_zone));\n            tz_name = cfg.time_zone.to_string();\n        }\n\n        tz.map(|tz| (tz, tz_name))\n    }\n\n    /// Return the measured length of the schedule, in minutes.\n    pub(crate) fn schedule_length_minutes(&self) -> u64 {\n        self.schedule.length_minutes()\n    }\n\n    /// Return the weekday and time of the next window, in human terms.\n    pub(crate) fn human_next_window(&self) -> String {\n        let naive_utc_dt = Utc::now().naive_utc();\n        let dt = (&self.time_zone).from_utc_datetime(&naive_utc_dt);\n        let next_window_minute_in_week = self.schedule.next_window_minute_in_week(&dt);\n\n        match next_window_minute_in_week {\n            Some(minute_in_week) => {\n                let (weekday, hour, minute) = utils::weekly_minute_as_weekday_time(minute_in_week);\n                format!(\n                    \"at {}:{:0>2} on {} ({}), subject to time zone caveats.\",\n                    hour, minute, weekday, self.tz_name\n                )\n            }\n            None => \"not found\".to_string(),\n        }\n    }\n\n    /// Return the remaining duration to next window, in human terms.\n    pub(crate) fn human_remaining(&self) -> String {\n        let datetime = chrono::Utc::now();\n        let remaining = self.schedule.remaining_to_datetime(&datetime);\n        match remaining {\n            None => \"not found\".to_string(),\n            Some(ref d) => WeeklyCalendar::human_remaining_duration(d)\n                .unwrap_or_else(|_| \"unknown\".to_string()),\n        }\n    }\n\n    /// Return some human-friendly information about `PeriodicStrategy`'s calendar.\n    pub(crate) fn calendar_summary(&self) -> String {\n        format!(\n            \"total schedule length {} minutes; next window {}\",\n            self.schedule_length_minutes(),\n            if self.tz_name() != \"UTC\" || self.tz_name() != \"Etc/UTC\" {\n                self.human_next_window()\n            } else {\n                // It is likely difficult for users to reason about UTC dates and times,\n                // so display remaining time, instead.\n                self.human_remaining()\n            }\n        )\n    }\n\n    /// Check if finalization is allowed.\n    pub(crate) fn can_finalize(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {\n        let naive_utc_dt = Utc::now().naive_utc();\n        let dt = (&self.time_zone).from_utc_datetime(&naive_utc_dt);\n        let allowed = self.schedule.contains_datetime(&dt);\n\n        trace!(\"periodic strategy, can finalize updates: {}\", allowed);\n\n        let res = future::ok(allowed);\n        Box::pin(res)\n    }\n\n    pub(crate) fn report_steady(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {\n        trace!(\"periodic strategy, report steady: {}\", true);\n\n        let res = future::ok(true);\n        Box::pin(res)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::{fragments, inputs};\n    use tokio::runtime as rt;\n\n    #[test]\n    fn test_default() {\n        let default = StrategyPeriodic::default();\n        assert_eq!(default.schedule.total_length_minutes(), 0);\n    }\n\n    #[test]\n    fn test_empty_can_finalize() {\n        let default = StrategyPeriodic::default();\n        let runtime = rt::Runtime::new().unwrap();\n        let steady = runtime.block_on(default.can_finalize()).unwrap();\n        assert!(!steady);\n    }\n\n    #[test]\n    fn test_report_steady() {\n        let default = StrategyPeriodic::default();\n        let runtime = rt::Runtime::new().unwrap();\n        let steady = runtime.block_on(default.report_steady()).unwrap();\n        assert!(steady);\n    }\n\n    #[test]\n    fn test_periodic_config() {\n        let cfg = parse_config_input(\"tests/fixtures/20-periodic-sample.toml\");\n        let strategy = StrategyPeriodic::new(cfg.updates).unwrap();\n        assert_eq!(strategy.schedule.total_length_minutes(), 3145);\n    }\n\n    #[test]\n    fn test_non_utc_time() {\n        use chrono::{Datelike, Timelike};\n\n        // Build a strategy that uses a non UTC time.\n        // Time zone is `America/Toronto` in `30-periodic-sample-non-utc.toml`.\n        let non_utc_time_cfg = parse_config_input(\"tests/fixtures/30-periodic-sample-non-utc.toml\");\n        // Create current datetime with non UTC time.\n        let naive_utc_dt = Utc::now().naive_utc();\n        let tz = Tz::named(&non_utc_time_cfg.updates.periodic.time_zone).unwrap();\n        let dt = (&tz).from_utc_datetime(&naive_utc_dt);\n        let weekday = dt.weekday();\n        let time = format!(\"{}:{}\", dt.hour(), dt.minute());\n        // Modify time windows to only allow naive time in non-UTC time zone's current and following minute.\n        let mut non_utc_time_update_input: inputs::UpdateInput = non_utc_time_cfg.updates;\n        non_utc_time_update_input.periodic.intervals = vec![inputs::PeriodicIntervalInput {\n            start_day: weekday.to_string(),\n            start_time: time,\n            length_minutes: 2,\n        }];\n\n        // Build a strategy that uses UTC.\n        // Time zone is not specified in `20-periodic-sample.toml` and so defaults to UTC.\n        let utc_cfg = parse_config_input(\"tests/fixtures/20-periodic-sample.toml\");\n        // Modify time windows to only allow naive time in non-UTC time zone's current and following minute.\n        let mut utc_update_input: inputs::UpdateInput = utc_cfg.updates;\n        utc_update_input.periodic.intervals = non_utc_time_update_input.periodic.intervals.clone();\n\n        let non_utc_strategy = StrategyPeriodic::new(non_utc_time_update_input).unwrap();\n        let runtime = rt::Runtime::new().unwrap();\n        let steady = runtime.block_on(non_utc_strategy.can_finalize()).unwrap();\n        assert_eq!(\n            non_utc_strategy.time_zone,\n            Tz::named(\"America/Toronto\").unwrap()\n        );\n        // Check that strategy allows reboot now.\n        assert!(steady);\n\n        let utc_strategy = StrategyPeriodic::new(utc_update_input).unwrap();\n        let runtime = rt::Runtime::new().unwrap();\n        let steady = runtime.block_on(utc_strategy.can_finalize()).unwrap();\n        assert_eq!(utc_strategy.time_zone, Tz::named(\"UTC\").unwrap());\n        // Check that reboot is NOT allowed for UTC strategy.\n        assert!(!steady);\n    }\n\n    #[test]\n    fn test_localtime() {\n        use std::matches;\n        use std::path::Path;\n        let local_time_path = Path::new(\"/etc/localtime\");\n        let expected_tz;\n        // If symlink `/etc/localtime` doesn't exist, we expect to default to UTC.\n        if read_link(local_time_path).is_err() {\n            expected_tz = Some(Tz::named(\"UTC\").unwrap());\n        } else if let Ok(tz_path) = local_time_path.canonicalize() {\n            if tz_path.starts_with(\"/run/host\") {\n                // Likely running in a toolbx container on a dev machine, where\n                // `/etc/localtime` symlinks into the host mounts. There's\n                // no point trying to work around this; the periodic strategy\n                // itself will also try to resolve it and fail and it feels\n                // awkward to add a toolbx specific hack there too.\n                return;\n            }\n            let tz_str = tz_path\n                .strip_prefix(Path::new(\"/usr/share/zoneinfo\"))\n                .unwrap()\n                .to_str()\n                .unwrap();\n            expected_tz = Some(Tz::named(tz_str).unwrap());\n        } else {\n            // `/etc/localtime` exists but points to an invalid time zone.\n            expected_tz = None;\n        }\n        let config = parse_config_input(\"tests/fixtures/31-periodic-sample-non-utc.toml\");\n        let strategy = StrategyPeriodic::new(config.updates);\n        match expected_tz {\n            Some(tz) => assert_eq!(strategy.unwrap().time_zone, tz),\n            // If we couldn't canonicalize `/etc/localtime` i.e. it points to an invalid\n            // location, make sure that we fail to create a new `StrategyPeriodic` struct.\n            None => assert!(matches!(strategy, Err { .. })),\n        }\n    }\n\n    fn parse_config_input(config_path: &str) -> inputs::ConfigInput {\n        let content = std::fs::read_to_string(config_path).unwrap();\n        let frag: fragments::ConfigFragment = toml::from_str(&content).unwrap();\n        inputs::ConfigInput::merge_fragments(vec![frag])\n    }\n}\n"
  },
  {
    "path": "src/update_agent/actor.rs",
    "content": "//! Update agent actor.\n\nuse super::{UpdateAgent, UpdateAgentInfo, UpdateAgentMachineState, UpdateAgentState};\nuse crate::rpm_ostree::{self, Release};\nuse crate::utils;\nuse actix::prelude::*;\nuse anyhow::{bail, format_err, Error, Result};\nuse fn_error_context::context;\nuse futures::prelude::*;\nuse log::trace;\nuse prometheus::{IntCounter, IntCounterVec, IntGauge};\nuse std::collections::BTreeSet;\nuse std::mem::discriminant;\nuse std::rc::Rc;\nuse std::time::Duration;\n\n/// Label for finalization attempts blocked due to active interactive user sessions.\npub static ACTIVE_USERSESSIONS_LABEL: &str = \"active_usersessions\";\n\nlazy_static::lazy_static! {\n    static ref LAST_REFRESH: IntGauge = register_int_gauge!(opts!(\n        \"zincati_update_agent_last_refresh_timestamp\",\n        \"UTC timestamp of update-agent last refresh tick.\"\n    )).unwrap();\n    static ref FINALIZATION_ATTEMPTS: IntCounter = register_int_counter!(opts!(\n        \"zincati_update_agent_finalization_attempts\",\n        \"Total number of attempts to finalize a staged deployment by the update agent.\"\n    )).unwrap();\n    static ref FINALIZATION_BLOCKED: IntCounterVec = register_int_counter_vec!(\n        \"zincati_update_agent_finalization_blocked_count\",\n        \"Total number of finalization attempts blocked due to reasons unrelated to update strategy.\",\n        &[\"reason\"]\n    ).unwrap();\n    static ref FINALIZATION_SUCCESS: IntCounter = register_int_counter!(opts!(\n        \"zincati_update_agent_finalization_successes\",\n        \"Total number of successful update finalizations by the update agent.\"\n    )).unwrap();\n}\n\nimpl Actor for UpdateAgent {\n    type Context = Context<Self>;\n\n    fn started(&mut self, ctx: &mut Self::Context) {\n        trace!(\"update agent started\");\n\n        if self.info.allow_downgrade {\n            log::warn!(\"client configuration allows (possibly vulnerable) downgrades via auto-updates logic\");\n        }\n\n        // Kick-start the state machine.\n        Self::tick_now(ctx);\n    }\n}\n\npub struct LastRefresh {}\n\nimpl Message for LastRefresh {\n    type Result = i64;\n}\n\nimpl Handler<LastRefresh> for UpdateAgent {\n    type Result = i64;\n\n    fn handle(&mut self, _msg: LastRefresh, _ctx: &mut Self::Context) -> Self::Result {\n        trace!(\"agent: request to get last refresh time\");\n        LAST_REFRESH.get()\n    }\n}\n\npub(crate) struct RefreshTick {}\n\nimpl Message for RefreshTick {\n    type Result = Result<(), Error>;\n}\n\nimpl Handler<RefreshTick> for UpdateAgent {\n    type Result = ResponseActFuture<Self, Result<(), Error>>;\n\n    fn handle(&mut self, _msg: RefreshTick, _ctx: &mut Self::Context) -> Self::Result {\n        // We need a clone of `info` because we need to move it into futures to ensure a\n        // long enough lifetime.\n        let update_agent_info = self.info.clone();\n        let lock = Rc::clone(&self.state);\n        let last_changed = Rc::clone(&self.state_changed);\n        let state_action = async move {\n            // Acquire RwLock to access state.\n            let mut agent_state_guard = lock.write().await;\n            // Consider `LAST_REFRESH` time to be when lock is acquired.\n            let tick_timestamp = chrono::Utc::now();\n            LAST_REFRESH.set(tick_timestamp.timestamp());\n\n            trace!(\n                \"update agent tick, current state: {:?}\",\n                agent_state_guard.machine_state\n            );\n            let prev_state = agent_state_guard.machine_state.clone();\n\n            match &prev_state {\n                UpdateAgentMachineState::StartState => {\n                    update_agent_info\n                        .tick_initialize(&mut agent_state_guard)\n                        .await\n                }\n                UpdateAgentMachineState::Initialized => {\n                    update_agent_info\n                        .tick_report_steady(&mut agent_state_guard.machine_state)\n                        .await\n                }\n                UpdateAgentMachineState::ReportedSteady => {\n                    update_agent_info\n                        .tick_check_updates(&mut agent_state_guard)\n                        .await\n                }\n                UpdateAgentMachineState::NoNewUpdate => {\n                    update_agent_info\n                        .tick_check_updates(&mut agent_state_guard)\n                        .await\n                }\n                UpdateAgentMachineState::UpdateAvailable((release, _)) => {\n                    let update = release.clone();\n                    update_agent_info\n                        .tick_stage_update(&mut agent_state_guard, update)\n                        .await\n                }\n                UpdateAgentMachineState::UpdateStaged((release, _)) => {\n                    let update = release.clone();\n                    update_agent_info\n                        .tick_finalize_update(&mut agent_state_guard.machine_state, update)\n                        .await\n                }\n                UpdateAgentMachineState::UpdateFinalized(release) => {\n                    let update = release.clone();\n                    update_agent_info\n                        .tick_end(&mut agent_state_guard.machine_state, update)\n                        .await\n                }\n                UpdateAgentMachineState::EndState => (),\n            };\n\n            // Update state_changed timestamp if necessary.\n            if discriminant(&prev_state) != discriminant(&agent_state_guard.machine_state) {\n                // In practice, this field will be monotonically increasing as we\n                // ensure that we only set it when a `RwLock` to state is acquired.\n                last_changed.set(chrono::Utc::now());\n            }\n\n            Self::refresh_delay(\n                update_agent_info.steady_interval,\n                &prev_state,\n                &agent_state_guard.machine_state,\n            )\n        };\n        let state_action = state_action.into_actor(self);\n        let update_machine = state_action.then(|pause, _actor, ctx| {\n            if let Some(pause) = pause {\n                log::trace!(\n                    \"scheduling next agent refresh in {} seconds\",\n                    pause.as_secs()\n                );\n                Self::tick_later(ctx, pause);\n            } else {\n                Self::tick_now(ctx);\n            }\n            actix::fut::ok(())\n        });\n\n        Box::pin(update_machine)\n    }\n}\n\nimpl UpdateAgent {\n    /// Schedule an immediate refresh of the state machine.\n    pub fn tick_now(ctx: &mut Context<Self>) {\n        ctx.notify(RefreshTick {})\n    }\n\n    /// Schedule a delayed refresh of the state machine.\n    pub fn tick_later(ctx: &mut Context<Self>, after: std::time::Duration) -> actix::SpawnHandle {\n        ctx.notify_later(RefreshTick {}, after)\n    }\n\n    /// Pausing interval between state-machine refresh cycles.\n    ///\n    /// This influences the pace of the update-agent refresh loop. Timing of the\n    /// state machine is not uniform. Some states benefit from more/less\n    /// frequent refreshes, or can be customized by the user.\n    fn refresh_delay(\n        steady_interval: Duration,\n        prev_state: &UpdateAgentMachineState,\n        cur_state: &UpdateAgentMachineState,\n    ) -> Option<Duration> {\n        if Self::should_tick_immediately(prev_state, cur_state) {\n            return None;\n        }\n\n        let (mut refresh_delay, should_jitter) = cur_state.get_refresh_delay(steady_interval);\n        if should_jitter {\n            refresh_delay = Self::add_jitter(refresh_delay);\n        };\n\n        Some(refresh_delay)\n    }\n\n    /// Return whether a transition from `prev_state` to `cur_state` warrants an immediate\n    /// tick.\n    fn should_tick_immediately(\n        prev_state: &UpdateAgentMachineState,\n        cur_state: &UpdateAgentMachineState,\n    ) -> bool {\n        // State changes trigger immediate tick/action.\n        if discriminant(prev_state) != discriminant(cur_state) {\n            // Unless we're transitioning from ReportedSteady to NoNewUpdate.\n            if !(*prev_state == UpdateAgentMachineState::ReportedSteady\n                && *cur_state == UpdateAgentMachineState::NoNewUpdate)\n            {\n                return true;\n            }\n        }\n        false\n    }\n\n    /// Add a small, random amount (0% to 10%) of jitter to a given period.\n    ///\n    /// This random jitter is useful to prevent clients from converging to\n    /// the same phase-locked loop.\n    fn add_jitter(period: std::time::Duration) -> std::time::Duration {\n        use rand::Rng;\n\n        let secs = period.as_secs();\n        let rand: u8 = rand::rng().random_range(0..=10);\n        let jitter = u64::max(secs / 100, 1).saturating_mul(u64::from(rand));\n        std::time::Duration::from_secs(secs.saturating_add(jitter))\n    }\n\n    /// Log at INFO level how many and which deployments will be excluded from being\n    /// future update targets.\n    fn log_excluded_depls(depls: &BTreeSet<Release>, actor: &UpdateAgentInfo) {\n        // Exclude booted deployment.\n        let mut other_depls = depls.clone();\n        if !other_depls.remove(&actor.identity.current_os) {\n            log::error!(\"could not find booted deployment in deployments\");\n            return; // Early return since this really should not happen.\n        }\n\n        let excluded_depls_count = other_depls.len();\n        if excluded_depls_count > 0 {\n            log::info!(\n                \"found {} other finalized deployment{}\",\n                excluded_depls_count,\n                if excluded_depls_count > 1 { \"s\" } else { \"\" }\n            );\n            for release in other_depls {\n                log::info!(\n                    \"deployment {} ({}) will be excluded from being a future update target\",\n                    release.version,\n                    release.payload\n                );\n            }\n        } else {\n            log::debug!(\n                \"no other local finalized deployments found; no update targets will be excluded.\"\n            );\n        }\n    }\n}\n\nimpl UpdateAgentInfo {\n    /// Initialize the update agent.\n    async fn tick_initialize(&self, state: &mut UpdateAgentState) {\n        trace!(\"update agent in start state\");\n        if self.enabled {\n            self.register_as_driver().await;\n        }\n        let local_depls = self.local_deployments().await;\n        match local_depls {\n            Ok(depls) => {\n                UpdateAgent::log_excluded_depls(&depls, self);\n                // Set denylist.\n                state.denylist = depls;\n            }\n            Err(e) => log::error!(\"failed to query local deployments: {}\", e),\n        }\n        let status;\n        if self.enabled {\n            status = \"initialization complete, auto-updates logic enabled\";\n            log::info!(\"{}\", status);\n            state.machine_state.initialized();\n            self.strategy.record_details();\n        } else {\n            status = \"initialization complete, auto-updates logic disabled by configuration\";\n            log::warn!(\"{}\", status);\n            state.machine_state.end();\n        }\n\n        utils::notify_ready();\n        utils::update_unit_status(status);\n    }\n\n    /// Try to report steady state.\n    async fn tick_report_steady(&self, state: &mut UpdateAgentMachineState) {\n        trace!(\"trying to report steady state\");\n\n        let is_steady = self.strategy.report_steady().await;\n        if is_steady {\n            log::info!(\"reached steady state, periodically polling for updates\");\n            utils::update_unit_status(\"periodically polling for updates\");\n            state.reported_steady();\n        }\n    }\n\n    /// Try to check for updates.\n    async fn tick_check_updates(&self, state: &mut UpdateAgentState) {\n        trace!(\"trying to check for udpates\");\n\n        let timestamp_now = chrono::Utc::now();\n        utils::update_unit_status(&format!(\n            \"periodically polling for updates (last checked {})\",\n            timestamp_now.format(\"%a %Y-%m-%d %H:%M:%S %Z\")\n        ));\n        let allow_downgrade = self.allow_downgrade;\n\n        let release = self\n            .cincinnati\n            .fetch_update_hint(&self.identity, state.denylist.clone(), allow_downgrade)\n            .await;\n\n        match release {\n            Some(release) => {\n                utils::update_unit_status(&format!(\"found update on remote: {}\", release.version));\n                state.machine_state.update_available(release);\n            }\n            None => {\n                state.machine_state.no_new_update();\n            }\n        }\n    }\n\n    /// Try to stage an update.\n    async fn tick_stage_update(&self, state: &mut UpdateAgentState, release: Release) {\n        trace!(\"trying to stage an update\");\n\n        let target = release.clone();\n        let deploy_outcome = self\n            .attempt_deploy(target)\n            .and_then(|release| self.confirm_valid_stream(state, release))\n            .await;\n\n        if let Err(e) = deploy_outcome {\n            log::error!(\"failed to stage deployment: {}\", e);\n            let fail_count =\n                UpdateAgentInfo::deploy_attempt_failed(&release, &mut state.machine_state);\n            let msg = format!(\n                \"trying to stage {} (failed attempts: {})\",\n                release.version, fail_count,\n            );\n            utils::update_unit_status(&msg);\n            log::trace!(\"{}\", msg);\n        };\n    }\n\n    /// Try to finalize an update.\n    async fn tick_finalize_update(&self, state: &mut UpdateAgentMachineState, release: Release) {\n        trace!(\"trying to finalize an update\");\n        FINALIZATION_ATTEMPTS.inc();\n\n        let strategy_can_finalize = self.strategy.can_finalize().await;\n        if !strategy_can_finalize {\n            utils::update_unit_status(&format!(\n                \"update staged: {}; reboot pending due to update strategy\",\n                &release.version\n            ));\n            // Reset number of postponements to `MAX_FINALIZE_POSTPONEMENTS`\n            // if strategy does not allow finalization.\n            state.update_staged(release);\n            return;\n        }\n\n        let usersessions_can_finalize = state.usersessions_can_finalize();\n        if !usersessions_can_finalize {\n            FINALIZATION_BLOCKED\n                .with_label_values(&[ACTIVE_USERSESSIONS_LABEL])\n                .inc();\n            utils::update_unit_status(&format!(\n                \"update staged: {}; reboot delayed due to active user sessions\",\n                release.version\n            ));\n            // Record postponement and postpone finalization.\n            state.record_postponement();\n            return;\n        }\n\n        match self.finalize_deployment(release).await {\n            Ok(release) => {\n                FINALIZATION_SUCCESS.inc();\n                let status_msg = format!(\"update finalized: {}\", release.version);\n                log::info!(\"{}\", &status_msg);\n                state.update_finalized(release);\n                utils::update_unit_status(&status_msg);\n            }\n            Err(e) => log::error!(\"failed to finalize deployment: {}\", e),\n        }\n    }\n\n    /// Actor job is done.\n    async fn tick_end(&self, state: &mut UpdateAgentMachineState, release: Release) {\n        let status_msg = format!(\"update applied, waiting for reboot: {}\", release.version);\n        log::info!(\"{}\", &status_msg);\n        state.end();\n        utils::update_unit_status(&status_msg);\n    }\n\n    /// Fetch and stage an update, in finalization-locked mode.\n    async fn attempt_deploy(&self, release: Release) -> Result<Release> {\n        log::info!(\n            \"target release '{}' selected, proceeding to stage it\",\n            release.version\n        );\n        let msg = rpm_ostree::StageDeployment {\n            release,\n            allow_downgrade: self.allow_downgrade,\n        };\n\n        self.rpm_ostree_actor\n            .send(msg)\n            .unwrap_or_else(|e| Err(e.into()))\n            .await\n    }\n\n    /// Record a failed deploy attempt and return the total number of\n    /// failed deployment attempts.\n    fn deploy_attempt_failed(release: &Release, state: &mut UpdateAgentMachineState) -> u8 {\n        let (is_abandoned, fail_count) = state.record_failed_deploy();\n        if is_abandoned {\n            log::warn!(\n                \"persistent deploy failure detected, target release '{}' abandoned\",\n                release.version\n            );\n        }\n        fail_count\n    }\n\n    /// List persistent (i.e. finalized) local deployments.\n    ///\n    /// This ignores deployments that have been only staged but not finalized in the\n    /// past, as they are acceptable as future update target.\n    async fn local_deployments(&self) -> Result<BTreeSet<Release>> {\n        let msg = rpm_ostree::QueryLocalDeployments { omit_staged: true };\n        self.rpm_ostree_actor\n            .send(msg)\n            .unwrap_or_else(|e| Err(e.into()))\n            .map_ok(move |depls| {\n                log::trace!(\"found {} local deployments\", depls.len());\n                depls\n            })\n            .await\n    }\n\n    /// Validate that pending deployment is coming from the correct update stream.\n    ///\n    /// This checks that the update stream for the pending deployment matches the current\n    /// one from agent identity.\n    /// The validation logic here is meant to run right after an update has been fetched,\n    /// thus it first ensures that a pending deployment exists with version and\n    /// checksum matching the one in input.\n    async fn is_pending_deployment_on_correct_stream(&self, release: Release) -> Result<bool> {\n        let msg = rpm_ostree::QueryPendingDeploymentStream {};\n        let query_res = self\n            .rpm_ostree_actor\n            .send(msg)\n            .unwrap_or_else(|e| Err(e.into()))\n            .await?;\n\n        let (pending, stream) = query_res.ok_or_else(|| {\n            format_err!(\n                \"expected pending deployment '{}', but found none\",\n                release.version\n            )\n        })?;\n\n        if pending.version != release.version {\n            bail!(\n                \"expected pending deployment '{}', but found '{}' instead\",\n                release.version,\n                pending.version\n            );\n        }\n        if pending.payload != release.payload {\n            bail!(\n                \"detected checksum mismatch for pending deployment '{}', got unexpected value '{}'\",\n                release.version,\n                release.payload,\n            );\n        }\n\n        if self.identity.stream != stream {\n            log::error!(\n                \"pending deployment {} expected to be on stream '{}', but found '{}' instead\",\n                pending.version,\n                self.identity.stream,\n                stream\n            );\n            Ok(false)\n        } else {\n            log::trace!(\n                \"validated stream '{}' for pending deployment {}\",\n                stream,\n                pending.version\n            );\n            Ok(true)\n        }\n    }\n\n    /// Finalize a deployment (unlock and reboot).\n    async fn finalize_deployment(&self, release: Release) -> Result<Release> {\n        log::info!(\n            \"staged deployment '{}' available, proceeding to finalize it\",\n            release.version\n        );\n\n        let msg = rpm_ostree::FinalizeDeployment { release };\n        self.rpm_ostree_actor\n            .send(msg)\n            .unwrap_or_else(|e| Err(e.into()))\n            .await\n    }\n\n    /// Attempt to register as the update driver for rpm-ostree.\n    async fn register_as_driver(&self) {\n        log::info!(\"registering as the update driver for rpm-ostree\");\n\n        let msg = rpm_ostree::RegisterAsDriver {};\n        self.rpm_ostree_actor\n            .send(msg)\n            .unwrap_or_else(|e| log::error!(\"failed to register as driver: {}\", e))\n            .await\n    }\n\n    /// Cleanup pending deployment.\n    async fn cleanup_pending_deployment(&self) {\n        let msg = rpm_ostree::CleanupPendingDeployment {};\n        let result = self\n            .rpm_ostree_actor\n            .send(msg)\n            .unwrap_or_else(|e| Err(e.into()))\n            .await;\n        if let Err(e) = result {\n            log::error!(\"failed to cleanup pending deployment: {}\", e)\n        };\n    }\n\n    /// Check that the pending deployment is from the correct update stream.\n    ///\n    /// If valid, advance the state-machine. If mistaken, clean up the pending deployment.\n    /// If unsure, bubble up errors in order to retry later.\n    #[context(\"failed to confirm stream\")]\n    async fn confirm_valid_stream(\n        &self,\n        state: &mut UpdateAgentState,\n        release: Release,\n    ) -> Result<()> {\n        // In some cases it may be impossible to validate/reject the update,\n        // e.g. because of temporary issues. The outer layers of the agent will\n        // try to handle these cases with retries.\n        let is_valid = self\n            .is_pending_deployment_on_correct_stream(release.clone())\n            .await?;\n\n        if !is_valid {\n            // Target deployment is not valid; scrub it, and remember to avoid it in the future.\n            self.cleanup_pending_deployment().await;\n            log::error!(\"abandoned and blocked deployment '{}'\", release.version);\n            state.denylist.insert(release.clone());\n            state.machine_state.update_abandoned();\n        } else {\n            // Pending deployment is on the correct stream, update can safely proceed.\n            let msg = format!(\"update staged: {}\", release.version);\n            utils::update_unit_status(&msg);\n            log::info!(\"{}\", msg);\n            state.machine_state.update_staged(release);\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_should_tick_immediately() {\n        use crate::rpm_ostree::Payload;\n        use crate::update_agent::MAX_FINALIZE_POSTPONEMENTS;\n        // Dummy `Release`.\n        let update = Release {\n            version: \"v1\".to_string(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n            age_index: None,\n        };\n\n        // Transition between states with different discriminants.\n        let prev_state = UpdateAgentMachineState::Initialized;\n        let cur_state = UpdateAgentMachineState::ReportedSteady;\n        assert!(UpdateAgent::should_tick_immediately(\n            &prev_state,\n            &cur_state\n        ));\n        let prev_state = UpdateAgentMachineState::NoNewUpdate;\n        let cur_state = UpdateAgentMachineState::UpdateAvailable((update.clone(), 0));\n        assert!(UpdateAgent::should_tick_immediately(\n            &prev_state,\n            &cur_state\n        ));\n        // Note we do NOT expect an immediate tick as this is a special case.\n        let prev_state = UpdateAgentMachineState::ReportedSteady;\n        let cur_state = UpdateAgentMachineState::NoNewUpdate;\n        assert!(!UpdateAgent::should_tick_immediately(\n            &prev_state,\n            &cur_state\n        ));\n\n        // Transition between states with same discriminants.\n        let prev_state = UpdateAgentMachineState::NoNewUpdate;\n        let cur_state = UpdateAgentMachineState::NoNewUpdate;\n        assert!(!UpdateAgent::should_tick_immediately(\n            &prev_state,\n            &cur_state\n        ));\n        let prev_state = UpdateAgentMachineState::UpdateAvailable((update.clone(), 0));\n        let cur_state = UpdateAgentMachineState::UpdateAvailable((update.clone(), 1));\n        assert!(!UpdateAgent::should_tick_immediately(\n            &prev_state,\n            &cur_state\n        ));\n        let prev_state =\n            UpdateAgentMachineState::UpdateStaged((update.clone(), MAX_FINALIZE_POSTPONEMENTS));\n        let cur_state = UpdateAgentMachineState::UpdateStaged((\n            update,\n            MAX_FINALIZE_POSTPONEMENTS.saturating_sub(1),\n        ));\n        assert!(!UpdateAgent::should_tick_immediately(\n            &prev_state,\n            &cur_state\n        ));\n    }\n}\n"
  },
  {
    "path": "src/update_agent/mod.rs",
    "content": "//! Update agent.\n\nmod actor;\npub use actor::LastRefresh;\n\nuse crate::cincinnati::Cincinnati;\nuse crate::config::Settings;\nuse crate::identity::Identity;\nuse crate::rpm_ostree::{Release, RpmOstreeClient};\nuse crate::strategy::UpdateStrategy;\nuse actix::Addr;\nuse anyhow::{Context, Result};\nuse chrono::prelude::*;\nuse prometheus::{IntCounter, IntGauge};\nuse serde::Deserialize;\nuse std::cell::Cell;\nuse std::collections::BTreeSet;\nuse std::fs;\nuse std::path::Path;\nuse std::rc::Rc;\nuse std::time::Duration;\nuse tokio::sync::RwLock;\n\n/// Default refresh interval for steady state (in seconds).\npub(crate) const DEFAULT_STEADY_INTERVAL_SECS: u64 = 300; // 5 minutes.\n\n/// Default refresh interval for end state (in seconds).\nconst END_INTERVAL_SECS: u64 = 10800; // 3 hours.\n\n/// Default tick/refresh period for the state machine (in seconds).\nconst DEFAULT_REFRESH_PERIOD_SECS: u64 = 300; // 5 minutes.\n\n/// Default amount of time to postpone finalizing an update if active\n/// interactive user sessions detected.\nconst DEFAULT_POSTPONEMENT_TIME_SECS: u64 = 60; // 1 minute.\n\n/// Maximum failed deploy attempts in a row in `UpdateAvailable` state\n/// before abandoning a target update.\nconst MAX_DEPLOY_ATTEMPTS: u8 = 12;\n\n/// This is undocumented; it's for testing Zincati in e.g. cosa where you have\n/// an interactive session but you don't want to wait for the full timeout.\nconst INTERACTIVE_SESSION_OVERRIDE: &str = \"/run/zincati/override-interactive-check\";\n\n/// Maximum number of postponements to finalizing an update in the\n/// `UpdateStaged` state before forcing an update finalization and reboot.\npub(crate) const MAX_FINALIZE_POSTPONEMENTS: u8 = 10;\n\nlazy_static::lazy_static! {\n    pub(crate) static ref ALLOW_DOWNGRADE: IntGauge = register_int_gauge!(opts!(\n        \"zincati_update_agent_updates_allow_downgrade\",\n        \"Whether downgrades via auto-updates logic are allowed.\"\n    )).unwrap();\n    static ref LATEST_STATE_CHANGE: IntGauge = register_int_gauge!(opts!(\n        \"zincati_update_agent_latest_state_change_timestamp\",\n        \"UTC timestamp of update-agent last state change.\"\n    )).unwrap();\n    pub(crate) static ref UPDATES_ENABLED: IntGauge = register_int_gauge!(opts!(\n        \"zincati_update_agent_updates_enabled\",\n        \"Whether auto-updates logic is enabled.\"\n    )).unwrap();\n    static ref POSTPONED_FINALIZATIONS: IntCounter = register_int_counter!(opts!(\n        \"zincati_update_agent_postponed_finalizations_total\",\n        \"Total number of update finalization postponements due to active users.\"\n    )).unwrap();\n    static ref DETECTED_ACTIVE_USERS: IntGauge = register_int_gauge!(opts!(\n        \"zincati_update_agent_finalization_detected_active_users\",\n        \"Number of active users detected by the update-agent.\"\n    )).unwrap();\n}\n\n/// JSON output from `loginctl list-sessions --json=short`.\n#[derive(Debug, Deserialize)]\npub struct SessionJson {\n    user: String,\n    tty: Option<String>,\n}\n\n/// A user login session with a tty.\npub struct InteractiveSession {\n    user: String,\n    /// Device file of session's tty.\n    tty_dev: String,\n}\n\n/// State machine for the agent.\n#[derive(Clone, Debug, PartialEq, Eq)]\nenum UpdateAgentMachineState {\n    /// Initial state upon actor start.\n    StartState,\n    /// Agent initialized.\n    Initialized,\n    /// Node steady, agent allowed to check for updates.\n    ReportedSteady,\n    /// No further updates available yet.\n    NoNewUpdate,\n    /// Update available from Cincinnati.\n    ///\n    /// The integer counter keeps track of how many times in a row this\n    /// update was attempted, but deploying failed. At `MAX_DEPLOY_ATTEMPTS`\n    /// a state transition is triggered to abandon the target update.\n    UpdateAvailable((Release, u8)),\n    /// Update staged by rpm-ostree.\n    ///\n    /// The integer counter keeps track of how many more finalization\n    /// postponements are permitted. If the counter reaches zero, the\n    /// finalization will proceed, disregarding any users logged in.\n    /// The counter is reset to `MAX_FINALIZE_POSTPONEMENTS` if a\n    /// finalization attempt failed due to update strategy constraints.\n    UpdateStaged((Release, u8)),\n    /// Update finalized by rpm-ostree.\n    UpdateFinalized(Release),\n    /// Final state upon actor end.\n    EndState,\n}\n\nimpl Default for UpdateAgentMachineState {\n    fn default() -> Self {\n        let start_state = UpdateAgentMachineState::StartState;\n        LATEST_STATE_CHANGE.set(chrono::Utc::now().timestamp());\n        start_state\n    }\n}\n\nimpl UpdateAgentMachineState {\n    /// Progress the machine to a new state.\n    fn transition_to(&mut self, state: Self) {\n        use std::mem::discriminant;\n        if discriminant(self) != discriminant(&state) {\n            LATEST_STATE_CHANGE.set(chrono::Utc::now().timestamp());\n        }\n\n        *self = state;\n    }\n\n    /// Transition to the Initialized state.\n    fn initialized(&mut self) {\n        let target = UpdateAgentMachineState::Initialized;\n        // Allowed starting states.\n        assert!(\n            *self == UpdateAgentMachineState::StartState,\n            \"transition not allowed: {:?} to {:?}\",\n            self,\n            target,\n        );\n\n        self.transition_to(target);\n    }\n\n    /// Transition to the ReportedSteady state.\n    fn reported_steady(&mut self) {\n        let target = UpdateAgentMachineState::ReportedSteady;\n        // Allowed starting states.\n        assert!(\n            *self == UpdateAgentMachineState::Initialized,\n            \"transition not allowed: {:?} to {:?}\",\n            self,\n            target,\n        );\n\n        self.transition_to(target);\n    }\n\n    /// Transition to the NoNewUpdate state.\n    fn no_new_update(&mut self) {\n        let target = UpdateAgentMachineState::NoNewUpdate;\n        // Allowed starting states.\n        assert!(\n            *self == UpdateAgentMachineState::ReportedSteady\n                || *self == UpdateAgentMachineState::NoNewUpdate,\n            \"transition not allowed: {:?} to {:?}\",\n            self,\n            target\n        );\n\n        self.transition_to(UpdateAgentMachineState::NoNewUpdate);\n    }\n\n    /// Transition to the UpdateAvailable state with a new release.\n    fn update_available(&mut self, update: Release) {\n        let target = UpdateAgentMachineState::UpdateAvailable((update, 0));\n        // Allowed starting states.\n        assert!(\n            *self == UpdateAgentMachineState::ReportedSteady\n                || *self == UpdateAgentMachineState::NoNewUpdate,\n            \"transition not allowed: {:?} to {:?}\",\n            self,\n            target\n        );\n\n        self.transition_to(target);\n    }\n\n    /// Record a failed deploy attempt in UpdateAvailable state.\n    ///\n    /// This returns a tuple containing a bool representing whether the target\n    /// update was abandoned and the total number of failed deployment attempts\n    /// (including the newly recorded failed attempt).\n    fn record_failed_deploy(&mut self) -> (bool, u8) {\n        let (release, attempts) = match self.clone() {\n            UpdateAgentMachineState::UpdateAvailable((r, a)) => (r, a),\n            _ => unreachable!(\"transition not allowed: record_failed_deploy on {:?}\", self,),\n        };\n        let fail_count = attempts.saturating_add(1);\n        let persistent_err = fail_count >= MAX_DEPLOY_ATTEMPTS;\n\n        if persistent_err {\n            self.update_abandoned();\n        } else {\n            self.deploy_failed(release, fail_count);\n        }\n\n        (persistent_err, fail_count)\n    }\n\n    /// Transition to the UpdateAvailable state after a deploy failure.\n    fn deploy_failed(&mut self, update: Release, fail_count: u8) {\n        let target = UpdateAgentMachineState::UpdateAvailable((update, fail_count));\n\n        self.transition_to(target);\n    }\n\n    /// Transition to the NoNewUpdate state after persistent deploy failure.\n    fn update_abandoned(&mut self) {\n        let target = UpdateAgentMachineState::NoNewUpdate;\n\n        self.transition_to(target);\n    }\n\n    /// Transition to the UpdateStaged state, setting the number of postponements\n    /// remaining to `MAX_FINALIZE_POSTPONEMENTS`.\n    fn update_staged(&mut self, update: Release) {\n        let target = UpdateAgentMachineState::UpdateStaged((update, MAX_FINALIZE_POSTPONEMENTS));\n\n        self.transition_to(target);\n    }\n\n    /// Determine whether to allow finalization based off of current state.\n    /// Returns a boolean indicating whether a finalization is permitted.\n    fn usersessions_can_finalize(&mut self) -> bool {\n        let interactive_sessions = get_interactive_user_sessions();\n\n        // If we failed to check for interactive sessions, assume nobody\n        // is logged in. Overall, we prefer forcibly finalizing updates\n        // instead of getting stuck in a retry loop here.\n        let sessions = interactive_sessions.unwrap_or_else(|e| {\n            log::error!(\"failed to check for interactive sessions: {}\", e);\n            log::warn!(\"assuming no active sessions and proceeding anyway\");\n            vec![]\n        });\n\n        self.handle_interactive_sessions(&sessions)\n    }\n\n    /// Helper for determining whether to allow a finalization by first checking whether\n    /// interactive sessions are present and then handling the appropriate response to current\n    /// state's remaining postponements (possibly broadcasting warning messages to active sessions).\n    ///\n    /// Returns a boolean indicating whether finalization is permitted.\n    fn handle_interactive_sessions(&self, interactive_sessions: &[InteractiveSession]) -> bool {\n        DETECTED_ACTIVE_USERS.set(interactive_sessions.len() as i64);\n        log::trace!(\n            \"handling interactive sessions, total: {}\",\n            interactive_sessions.len()\n        );\n\n        // Happy path, nobody to wait for.\n        if interactive_sessions.is_empty() {\n            log::debug!(\"no interactive sessions detected\");\n            return true;\n        }\n\n        // Allow even with interactive sessions if we're overriding this check\n        if Path::new(INTERACTIVE_SESSION_OVERRIDE).exists() {\n            log::debug!(\n                \"ignoring interactive sessions due to {}\",\n                INTERACTIVE_SESSION_OVERRIDE\n            );\n            return true;\n        }\n\n        let (release, postponements_remaining) = match self {\n            UpdateAgentMachineState::UpdateStaged((r, p)) => (r, *p),\n            _ => unreachable!(\n                \"transition not allowed: handle_interactive_sessions on {:?}\",\n                self,\n            ),\n        };\n\n        // Maximum grace period reached, not delaying any further.\n        if postponements_remaining == 0 {\n            log::warn!(\"reached end of grace period while waiting for interactive sessions\");\n            return true;\n        }\n\n        if postponements_remaining == MAX_FINALIZE_POSTPONEMENTS {\n            let max_reboot_delay_secs =\n                DEFAULT_POSTPONEMENT_TIME_SECS.saturating_mul(MAX_FINALIZE_POSTPONEMENTS as u64);\n            log::warn!(\n                \"interactive sessions detected, entering grace period (maximum {})\",\n                format_seconds(max_reboot_delay_secs)\n            );\n            let warning_msg = format_reboot_warning(max_reboot_delay_secs, &release.version);\n            broadcast(&warning_msg, interactive_sessions);\n        } else if postponements_remaining == 1 {\n            log::warn!(\"last attempt to wait for the end of all interactive sessions\");\n            let warning_msg =\n                format_reboot_warning(DEFAULT_POSTPONEMENT_TIME_SECS, &release.version);\n            broadcast(&warning_msg, interactive_sessions);\n        }\n\n        false\n    }\n\n    /// Record an additional postponement in machine's state (reduce the number of remaining\n    /// postponements allowed by one) after a finalization postponement.\n    fn record_postponement(&mut self) {\n        let (release, postponements_remaining) = match self.clone() {\n            UpdateAgentMachineState::UpdateStaged((r, p)) => (r, p),\n            _ => unreachable!(\n                \"transition not allowed: handle_interactive_sessions on {:?}\",\n                self,\n            ),\n        };\n\n        POSTPONED_FINALIZATIONS.inc();\n        self.reboot_postponed(release, postponements_remaining.saturating_sub(1));\n    }\n\n    /// Transition to the UpdateStaged state, setting the number of postponements\n    /// remaining to postponements_remaining.\n    fn reboot_postponed(&mut self, update: Release, postponements_remaining: u8) {\n        let target = UpdateAgentMachineState::UpdateStaged((update, postponements_remaining));\n\n        self.transition_to(target);\n    }\n\n    /// Transition to the UpdateFinalized state.\n    fn update_finalized(&mut self, update: Release) {\n        let target = UpdateAgentMachineState::UpdateFinalized(update);\n\n        self.transition_to(target);\n    }\n\n    /// Transition to the End state.\n    fn end(&mut self) {\n        let target = UpdateAgentMachineState::EndState;\n\n        self.transition_to(target);\n    }\n\n    /// Return the amount of delay between refreshes for this state, and whether\n    /// jitter should be added.\n    fn get_refresh_delay(&self, steady_interval: Duration) -> (Duration, bool) {\n        match self {\n            UpdateAgentMachineState::ReportedSteady | UpdateAgentMachineState::NoNewUpdate => {\n                (steady_interval, true)\n            }\n            UpdateAgentMachineState::UpdateStaged((_, postponements)) => {\n                // If postponements is less than `MAX_FINALIZE_POSTPONEMENTS`, that means the current tick\n                // led to a postponment, and so we should add a delay of `DEFAULT_POSTPONEMENT_TIME_SECS`.\n                if *postponements < MAX_FINALIZE_POSTPONEMENTS {\n                    (Duration::from_secs(DEFAULT_POSTPONEMENT_TIME_SECS), false)\n                } else {\n                    (Duration::from_secs(DEFAULT_REFRESH_PERIOD_SECS), true)\n                }\n            }\n            UpdateAgentMachineState::EndState => (Duration::from_secs(END_INTERVAL_SECS), true),\n            _ => (Duration::from_secs(DEFAULT_REFRESH_PERIOD_SECS), true),\n        }\n    }\n}\n\n/// Update agent.\n#[derive(Debug)]\npub(crate) struct UpdateAgent {\n    /// Current state of the agent.\n    /// We use an `Rc` because consumers of this field will likely need to\n    /// own it (e.g. consumers in futures).\n    state: Rc<RwLock<UpdateAgentState>>,\n    /// Timestamp of last state transition.\n    /// Behind `Rc` for same reason as above.\n    /// `Cell` is sufficient because the update_agent actor runs on a single thread (SystemArbiter).\n    state_changed: Rc<Cell<DateTime<Utc>>>,\n    /// Update agent's information.\n    info: UpdateAgentInfo,\n}\n\n/// Non-atomic read-write state of the update agent.\n#[derive(Debug, Default)]\npub(crate) struct UpdateAgentState {\n    /// Current state of the agent state machine.\n    machine_state: UpdateAgentMachineState,\n    /// List of releases to ignore.\n    denylist: BTreeSet<Release>,\n}\n\n/// Read-only information about the update agent.\n#[derive(Debug, Clone)]\npub(crate) struct UpdateAgentInfo {\n    /// Whether to allow automatic downgrades.\n    allow_downgrade: bool,\n    /// Cincinnati service.\n    cincinnati: Cincinnati,\n    /// Whether to enable auto-updates logic.\n    enabled: bool,\n    /// Agent identity.\n    identity: Identity,\n    /// Refresh interval in steady state.\n    steady_interval: Duration,\n    /// rpm-ostree client actor.\n    rpm_ostree_actor: Addr<RpmOstreeClient>,\n    /// Update strategy.\n    strategy: UpdateStrategy,\n}\n\nimpl UpdateAgent {\n    /// Build an update agent with the given config.\n    pub(crate) fn with_config(cfg: Settings, rpm_ostree_addr: Addr<RpmOstreeClient>) -> Self {\n        let steady_secs = cfg.steady_interval_secs.get();\n        Self {\n            state: Rc::new(RwLock::new(UpdateAgentState::default())),\n            state_changed: Rc::new(Cell::new(chrono::Utc::now())),\n            info: UpdateAgentInfo {\n                allow_downgrade: cfg.allow_downgrade,\n                cincinnati: cfg.cincinnati,\n                enabled: cfg.enabled,\n                identity: cfg.identity,\n                rpm_ostree_actor: rpm_ostree_addr,\n                steady_interval: Duration::from_secs(steady_secs),\n                strategy: cfg.strategy,\n            },\n        }\n    }\n}\n\n/// Attempt to broadcast msg to sessions.\nfn broadcast(msg: &str, sessions: &[InteractiveSession]) {\n    let mut sessions_broadcasted: usize = 0;\n\n    let broadcast_msg = format!(\n        \"\\nBroadcast message from Zincati at {}:\\n{}\\n\",\n        chrono::Utc::now().format(\"%a %Y-%m-%d %H:%M:%S %Z\"),\n        msg\n    );\n\n    for session in sessions.iter() {\n        // Write message to tty device.\n        log::trace!(\n            \"Attempting to broadcast a message to user {} at {}\",\n            &session.user,\n            &session.tty_dev\n        );\n        if let Err(e) = fs::write(&session.tty_dev, &broadcast_msg) {\n            log::error!(\"failed to write to {}: {}\", &session.tty_dev, e);\n            continue;\n        };\n\n        sessions_broadcasted = sessions_broadcasted.saturating_add(1);\n    }\n\n    if sessions.len() != sessions_broadcasted {\n        log::warn!(\n            \"{} interactive sessions found, but only broadcasted to {}\",\n            sessions.len(),\n            sessions_broadcasted\n        );\n    }\n}\n\n/// Get sessions with logged in interactive users using `loginctl`.\n/// Returns a Result with vector of `SessionsJson` if no error.\nfn get_interactive_user_sessions() -> Result<Vec<InteractiveSession>> {\n    let cmdrun = std::process::Command::new(\"loginctl\")\n        .arg(\"list-sessions\")\n        .arg(\"--json=short\")\n        .output()\n        .context(\"failed to run `loginctl` binary\")?;\n\n    if !cmdrun.status.success() {\n        anyhow::bail!(\n            \"`loginctl` failed to list current sessions: {}\",\n            String::from_utf8_lossy(&cmdrun.stderr)\n        );\n    }\n\n    let sessions: Vec<SessionJson> = serde_json::from_slice(&cmdrun.stdout)\n        .context(\"failed to deserialize output of `loginctl`\")?;\n\n    // Filter out sessions that aren't interactive (don't have a tty), and map\n    // these sessions into an `InteractiveSession` struct.\n    let interactive_sessions: Vec<InteractiveSession> = sessions\n        .into_iter()\n        .filter_map(|session| match session.tty {\n            Some(mut tty) => {\n                tty.insert_str(0, \"/dev/\");\n                Some(InteractiveSession {\n                    user: session.user,\n                    tty_dev: tty,\n                })\n            }\n            _ => {\n                log::debug!(\n                    \"found user {} with no tty, user considered non-interactive\",\n                    session.user\n                );\n                None\n            }\n        })\n        .collect();\n\n    Ok(interactive_sessions)\n}\n\n/// Returns a warning string about the time until reboot and the release\n/// that is staged.\nfn format_reboot_warning(seconds: u64, release_ver: &str) -> String {\n    let time_till_reboot = format_seconds(seconds);\n\n    format!(\n        \"New update {} is available and has been deployed.\\n\\\n        If permitted by the update strategy, Zincati will reboot into this update when\\n\\\n        all interactive users have logged out, or in {}, whichever comes\\n\\\n        earlier. Please log out of all active sessions in order to let the auto-update\\n\\\n        process continue.\",\n        release_ver, time_till_reboot\n    )\n}\n\n/// Helper to return a human-friendly version of seconds.\n/// Example: 65 seconds would be converted to 1 minute and 5 seconds.\nfn format_seconds(seconds: u64) -> String {\n    let mut time_till_reboot = if seconds / 60 >= 1 {\n        format!(\n            \"{} minute{}{}\",\n            seconds / 60,\n            if seconds / 60 == 1 { \"\" } else { \"s\" },\n            if !seconds.is_multiple_of(60) {\n                \" and \"\n            } else {\n                \"\"\n            }\n        )\n    } else {\n        String::from(\"\")\n    };\n    if !seconds.is_multiple_of(60) {\n        time_till_reboot.push_str(&format!(\n            \"{} second{}\",\n            seconds % 60,\n            if seconds % 60 == 1 { \"\" } else { \"s\" }\n        ))\n    }\n\n    time_till_reboot\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::rpm_ostree::{Payload, Release};\n    use std::{thread, time};\n\n    #[test]\n    fn default_state() {\n        assert_eq!(\n            UpdateAgentMachineState::default(),\n            UpdateAgentMachineState::StartState\n        );\n    }\n\n    #[test]\n    fn state_machine_happy_path() {\n        let steady_interval = Duration::from_secs(DEFAULT_STEADY_INTERVAL_SECS);\n        let default_interval = Duration::from_secs(DEFAULT_REFRESH_PERIOD_SECS);\n\n        let mut machine = UpdateAgentMachineState::default();\n        assert_eq!(machine, UpdateAgentMachineState::StartState);\n\n        machine.initialized();\n        assert_eq!(machine, UpdateAgentMachineState::Initialized);\n\n        machine.reported_steady();\n        assert_eq!(machine, UpdateAgentMachineState::ReportedSteady);\n\n        let state_change_time_before = LATEST_STATE_CHANGE.get();\n        thread::sleep(time::Duration::from_secs(1));\n        machine.no_new_update(); // ReportedSteady to NoNewUpdate.\n        let state_change_time_after = LATEST_STATE_CHANGE.get();\n        assert_eq!(machine, UpdateAgentMachineState::NoNewUpdate);\n        assert_ne!(state_change_time_before, state_change_time_after);\n        let (delay, should_jitter) = machine.get_refresh_delay(steady_interval);\n        assert_eq!(delay, steady_interval);\n        assert!(should_jitter);\n\n        let state_change_time_before = LATEST_STATE_CHANGE.get();\n        thread::sleep(time::Duration::from_secs(1));\n        machine.no_new_update(); // NoNewUpdate to NoNewUpdate.\n        let state_change_time_after = LATEST_STATE_CHANGE.get();\n        assert_eq!(machine, UpdateAgentMachineState::NoNewUpdate);\n        // Transitioning to own state not considered state change.\n        assert_eq!(state_change_time_before, state_change_time_after);\n\n        let update = Release {\n            version: \"v1\".to_string(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n            age_index: None,\n        };\n        machine.update_available(update.clone());\n        assert_eq!(\n            machine,\n            UpdateAgentMachineState::UpdateAvailable((update.clone(), 0))\n        );\n\n        let (persistent_err, _) = machine.record_failed_deploy();\n        assert!(!persistent_err);\n        assert_eq!(\n            machine,\n            UpdateAgentMachineState::UpdateAvailable((update.clone(), 1))\n        );\n        let (delay, should_jitter) = machine.get_refresh_delay(steady_interval);\n        assert_eq!(delay, default_interval);\n        assert!(should_jitter);\n\n        machine.update_staged(update.clone());\n        assert_eq!(\n            machine,\n            UpdateAgentMachineState::UpdateStaged((update.clone(), MAX_FINALIZE_POSTPONEMENTS))\n        );\n\n        machine.update_finalized(update.clone());\n        assert_eq!(machine, UpdateAgentMachineState::UpdateFinalized(update));\n\n        machine.end();\n        assert_eq!(machine, UpdateAgentMachineState::EndState);\n    }\n\n    #[test]\n    fn test_fsm_abandon_update() {\n        let update = Release {\n            version: \"v1\".to_string(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n            age_index: None,\n        };\n        let mut machine = UpdateAgentMachineState::NoNewUpdate;\n\n        machine.update_available(update.clone());\n        assert_eq!(\n            machine,\n            UpdateAgentMachineState::UpdateAvailable((update.clone(), 0))\n        );\n\n        // MAX-1 temporary failures.\n        for attempt in 1..MAX_DEPLOY_ATTEMPTS {\n            let (persistent_err, _) = machine.record_failed_deploy();\n            assert!(!persistent_err);\n            assert_eq!(\n                machine,\n                UpdateAgentMachineState::UpdateAvailable((update.clone(), attempt))\n            )\n        }\n\n        // Persistent error threshold reached.\n        let (persistent_err, _) = machine.record_failed_deploy();\n        assert!(persistent_err);\n        assert_eq!(machine, UpdateAgentMachineState::NoNewUpdate);\n    }\n\n    #[test]\n    fn test_fsm_postpone_finalize() {\n        let steady_interval = Duration::from_secs(DEFAULT_STEADY_INTERVAL_SECS);\n        let default_interval = Duration::from_secs(DEFAULT_REFRESH_PERIOD_SECS);\n        let postponement_interval = Duration::from_secs(DEFAULT_POSTPONEMENT_TIME_SECS);\n        let update = Release {\n            version: \"v1\".to_string(),\n            payload: Payload::try_from(\"quay.io/fedora/fedora-coreos:oci-mock\").unwrap(),\n            age_index: None,\n        };\n        let mut machine = UpdateAgentMachineState::UpdateAvailable((update.clone(), 0));\n        let (delay, should_jitter) = machine.get_refresh_delay(steady_interval);\n        assert_eq!(delay, default_interval);\n        assert!(should_jitter);\n\n        machine.update_staged(update.clone());\n        assert_eq!(\n            machine,\n            UpdateAgentMachineState::UpdateStaged((update.clone(), MAX_FINALIZE_POSTPONEMENTS))\n        );\n\n        // Set up empty interactive sessions.\n        let no_interactive_sessions: Vec<InteractiveSession> = vec![];\n        let can_finalize = machine.handle_interactive_sessions(&no_interactive_sessions);\n        assert!(can_finalize);\n        assert_eq!(\n            machine,\n            UpdateAgentMachineState::UpdateStaged((update.clone(), MAX_FINALIZE_POSTPONEMENTS))\n        );\n        let (delay, should_jitter) = machine.get_refresh_delay(steady_interval);\n        assert_eq!(delay, default_interval);\n        assert!(should_jitter);\n\n        // Set up dummy interactive sessions.\n        let fake_tty_path = tempfile::tempdir_in(\"/tmp\").unwrap();\n        let fake_tty_path_str = fake_tty_path.path().to_str().unwrap();\n        let fake_tty = format!(\"{}/tty1\", fake_tty_path_str);\n        let fake_session = InteractiveSession {\n            user: String::from(\"fakeuser\"),\n            tty_dev: String::from(&fake_tty),\n        };\n        let interactive_sessions_present: Vec<InteractiveSession> = vec![fake_session];\n\n        // Postpone MAX_FINALIZE_POSTPONEMENTS times (counting from 1).\n        for finalization_attempt in 1..MAX_FINALIZE_POSTPONEMENTS + 1 {\n            let can_finalize = machine.handle_interactive_sessions(&interactive_sessions_present);\n            assert!(!can_finalize);\n            machine.record_postponement(); // as we cannot finalize.\n            let postponement_remaining =\n                MAX_FINALIZE_POSTPONEMENTS.saturating_sub(finalization_attempt);\n            assert_eq!(\n                machine,\n                UpdateAgentMachineState::UpdateStaged((update.clone(), postponement_remaining))\n            );\n            let (delay, should_jitter) = machine.get_refresh_delay(steady_interval);\n            assert_eq!(delay, postponement_interval);\n            assert!(!should_jitter);\n        }\n\n        // Sanity check final broadcasted message.\n        let tty_contents = fs::read_to_string(&fake_tty).unwrap();\n        assert!(tty_contents.contains(\"Broadcast message from Zincati\"));\n        assert!(tty_contents.contains(&update.version));\n        assert!(tty_contents.contains(&format_seconds(DEFAULT_POSTPONEMENT_TIME_SECS)));\n\n        // Reached 0 remaining postponements.\n        let can_finalize = machine.handle_interactive_sessions(&interactive_sessions_present);\n        assert!(can_finalize);\n        assert_eq!(machine, UpdateAgentMachineState::UpdateStaged((update, 0)));\n    }\n\n    #[test]\n    fn test_format_seconds() {\n        assert_eq!(\"1 second\", format_seconds(1));\n        assert_eq!(\"2 seconds\", format_seconds(2));\n        assert_eq!(\"1 minute\", format_seconds(60));\n        assert_eq!(\"1 minute and 1 second\", format_seconds(60 + 1));\n        assert_eq!(\"1 minute and 30 seconds\", format_seconds(60 + 30));\n        assert_eq!(\"2 minutes\", format_seconds(2 * 60));\n        assert_eq!(\"42 minutes and 23 seconds\", format_seconds(42 * 60 + 23));\n    }\n}\n"
  },
  {
    "path": "src/utils.rs",
    "content": "//! Miscellaneous utility functions.\n\nuse libsystemd::daemon::{notify, NotifyState};\n\n/// Helper function to update unit's status text.\npub(crate) fn update_unit_status(status: &str) {\n    sd_notify(&[NotifyState::Status(status.to_string())]);\n}\n\n/// Helper function to notify the service manager that Zincati start up is finished and\n/// configuration is loaded.\npub(crate) fn notify_ready() {\n    sd_notify(&[NotifyState::Ready]);\n}\n\n/// Helper function to notify the service manager that Zincati is stopping.\npub(crate) fn notify_stopping() {\n    sd_notify(&[NotifyState::Stopping]);\n}\n\n/// Helper function to send notifications to the service manager about service status changes.\n/// Log errors if unsuccessful.\nfn sd_notify(state: &[NotifyState]) {\n    match notify(false, state) {\n        Err(e) => log::error!(\n            \"failed to notify service manager of service status change: {}\",\n            e\n        ),\n        Ok(sent) => {\n            if !sent {\n                log::error!(\"status notifications not supported for this service\");\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/weekly/mod.rs",
    "content": "//! Calendar-windows for events recurring on weekly basis.\n//!\n//! This contains helper logic to handle intervals of time which recur every week:\n//!  * `WeeklyWindow`: a continuous interval over a single week.\n//!  * `WeeklyCalendar`: a set of intervals on a weekly calendar.\n\n// TODO(lucab): stabilize and split this to its own `weekly` crate.\n\npub(crate) mod utils;\n\nuse anyhow::{ensure, Result};\nuse chrono::{DateTime, TimeZone, Utc};\nuse fn_error_context::context;\nuse intervaltree::{Element, IntervalTree};\nuse serde::{Serialize, Serializer};\nuse std::cmp::Ordering;\nuse std::fmt::Write;\nuse std::ops::Range;\nuse std::time::Duration;\n\n/// Whole week duration, in minutes.\npub(crate) const MAX_WEEKLY_MINS: u32 = 7 * 24 * 60;\n\n/// Whole week duration, in seconds.\npub(crate) const MAX_WEEKLY_SECS: u64 = (MAX_WEEKLY_MINS as u64) * 60;\n\n/// A weekly point in time, as minutes since beginning of week (Monday 00:00).\npub(crate) type MinuteInWeek = u32;\n\n/// Calendar for periodic time-windows, recurring on weekly basis.\n#[derive(Clone, Debug)]\npub struct WeeklyCalendar {\n    /// An immutable set of (possibly overlapping) intervals.\n    windows: IntervalTree<MinuteInWeek, WeeklyWindow>,\n}\n\nimpl WeeklyCalendar {\n    /// Create a calendar from a vector of weekly windows.\n    pub fn new(input: Vec<WeeklyWindow>) -> Self {\n        let intervals = input\n            .into_iter()\n            .map(|win| Element::from((win.range_weekly_minutes(), win)));\n\n        Self {\n            windows: intervals.collect(),\n        }\n    }\n\n    /// Return whether datetime is contained in this weekly calendar.\n    pub fn contains_datetime(&self, datetime: &DateTime<impl TimeZone>) -> bool {\n        let timepoint = utils::datetime_as_weekly_minute(datetime);\n        self.windows.query_point(timepoint).count() > 0\n    }\n\n    /// Return the minutes since the beginning of the week of the next window\n    /// containing the given datetime.\n    ///\n    /// This returns `None` if no windows are reachable.\n    pub fn next_window_minute_in_week(\n        &self,\n        datetime: &DateTime<impl TimeZone>,\n    ) -> Option<MinuteInWeek> {\n        if self.is_empty() {\n            return None;\n        }\n\n        // Already in a window, return now.\n        if self.contains_datetime(datetime) {\n            return Some(utils::datetime_as_weekly_minute(datetime));\n        }\n\n        let timepoint = utils::datetime_as_weekly_minute(datetime);\n        // Next window is this week.\n        if let Some(next) = self\n            .windows\n            .iter_sorted()\n            .find(|x| x.range.start >= timepoint)\n        {\n            let next_minute_in_week = next.range.start;\n            return Some(next_minute_in_week);\n        };\n\n        // Next window is not this week.\n        let first_window_next_week = self\n            .windows\n            .iter_sorted()\n            .next()\n            .expect(\"unexpected empty weekly calendar\")\n            .range\n            .start;\n        Some(first_window_next_week)\n    }\n\n    /// Return the duration remaining till the next window containing the given datetime.\n    ///\n    /// This returns `None` if no windows are reachable.\n    pub fn remaining_to_datetime(&self, datetime: &DateTime<Utc>) -> Option<chrono::Duration> {\n        if self.is_empty() {\n            return None;\n        }\n\n        // Already in a window, zero minutes.\n        if self.contains_datetime(datetime) {\n            return Some(chrono::Duration::zero());\n        }\n\n        let timepoint = utils::datetime_as_weekly_minute(datetime);\n        // Next window is this week, just subtract remaining minutes.\n        if let Some(next) = self\n            .windows\n            .iter_sorted()\n            .find(|x| x.range.start >= timepoint)\n        {\n            let remaining_mins = next.range.start.saturating_sub(timepoint);\n            return Some(chrono::Duration::minutes(i64::from(remaining_mins)));\n        };\n\n        // Next window is not this week, wrap remaining minutes to the first\n        // window of the next week (calendar has been already verified non-empty).\n        let remaining_mins = {\n            let remaining_this_week: i64 = MAX_WEEKLY_MINS.saturating_sub(timepoint).into();\n            let first_window_next_week = self\n                .windows\n                .iter_sorted()\n                .next()\n                .expect(\"unexpected empty weekly calendar\");\n            remaining_this_week.saturating_add(first_window_next_week.range.start.into())\n        };\n        Some(chrono::Duration::minutes(remaining_mins))\n    }\n\n    /// Format remaining duration till the next window in human terms.\n    pub fn human_remaining_duration(remaining: &chrono::Duration) -> Result<String> {\n        if remaining.is_zero() {\n            return Ok(\"now\".to_string());\n        }\n\n        let mut human_readable = \"in\".to_string();\n        let days = remaining.num_days() % 7;\n        let earlier_output = if days > 0 {\n            write!(&mut human_readable, \" {}d\", days)?;\n            true\n        } else {\n            false\n        };\n        let hours = remaining.num_hours() % 24;\n        if hours > 0 || earlier_output {\n            write!(&mut human_readable, \" {}h\", hours)?;\n        }\n        let minutes = remaining.num_minutes() % 60;\n        write!(&mut human_readable, \" {}m\", minutes)?;\n\n        Ok(human_readable)\n    }\n\n    /// Return the measured length of the calendar, in minutes.\n    ///\n    /// In case of overlapping windows, measured length is the actual amount\n    /// of weekly minutes in the calendar. Overlapped intervals are coalesced\n    /// in order to avoid double-counting.\n    #[allow(clippy::reversed_empty_ranges)]\n    pub fn length_minutes(&self) -> u64 {\n        let mut measured = 0u32;\n        let mut last_range = Range {\n            start: 0u32,\n            end: 0u32,\n        };\n\n        for win in self.windows.iter_sorted() {\n            if win.range.start > last_range.end {\n                // Non-overlapping window, update accumulator and use this as last range.\n                let last_length = last_range\n                    .end\n                    .saturating_sub(last_range.start)\n                    .saturating_sub(1);\n                measured = measured.saturating_add(last_length);\n                last_range = win.range.clone();\n            } else {\n                // Overlapping window, coalesce into the last range.\n                last_range.end = u32::max(last_range.end, win.range.end);\n            };\n        }\n        // Account for the still pending length of the last range.\n        let last_length = last_range\n            .end\n            .saturating_sub(last_range.start)\n            .saturating_sub(1);\n        measured = measured.saturating_add(last_length);\n\n        u64::from(measured)\n    }\n\n    /// Return true if the calendar contains no time-windows.\n    pub fn is_empty(&self) -> bool {\n        self.windows.iter().next().is_none()\n    }\n\n    /// Return total length of all windows in the calendar, in minutes.\n    ///\n    /// In case of overlapping windows, total length can be larger than the\n    /// actual amount of weekly minutes in the calendar.\n    #[cfg(test)]\n    pub fn total_length_minutes(&self) -> u64 {\n        self.windows.iter().fold(0u64, |len, win| {\n            len.saturating_add(win.value.length_minutes().into())\n        })\n    }\n\n    /// Return all weekly windows (if any) which contain a given datetime.\n    #[cfg(test)]\n    pub fn containing_windows(&self, datetime: &DateTime<impl TimeZone>) -> Vec<&WeeklyWindow> {\n        let timepoint = utils::datetime_as_weekly_minute(datetime);\n        self.windows\n            .query_point(timepoint)\n            .map(|elem| &elem.value)\n            .collect()\n    }\n}\n\nimpl Serialize for WeeklyCalendar {\n    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        use serde::ser::SerializeSeq;\n\n        let len = self.windows.iter().count();\n        let mut seq = serializer.serialize_seq(Some(len))?;\n        for interval in self.windows.iter() {\n            seq.serialize_element(&interval.value)?;\n        }\n        seq.end()\n    }\n}\n\nimpl Default for WeeklyCalendar {\n    fn default() -> Self {\n        Self::new(vec![])\n    }\n}\n\n/// Timespan with a fixed duration, recurring on weekly basis.\n///\n/// Length duration is capped so that a window never crosses week boundary.\n#[derive(Clone, Debug, Eq, Serialize)]\npub struct WeeklyWindow {\n    start_day: chrono::Weekday,\n    start_hour: u8,\n    start_minute: u8,\n    length: Duration,\n}\n\nimpl WeeklyWindow {\n    /// Parse a timespan into weekly windows.\n    ///\n    /// On success, this returns a non-empty vector with at most two weekly windows.\n    #[context(\"failed to parse timespan into weekly windows\")]\n    pub fn parse_timespan(\n        start_day: chrono::Weekday,\n        start_hour: u8,\n        start_minute: u8,\n        length: Duration,\n    ) -> Result<Vec<Self>> {\n        // Sanity check inputs (start and length).\n        ensure!(\n            start_hour <= 24 && start_minute <= 59,\n            \"invalid start time: {}:{}\",\n            start_hour,\n            start_minute\n        );\n        utils::check_duration(&length)?;\n\n        // Chop length at week boundary. Any seconds past Sunday end are carried into\n        // remaining length.\n        let remaining_len = {\n            let start = utils::time_as_weekly_minute(start_day, start_hour, start_minute);\n            let end_of_timespan_secs = u64::from(start)\n                .saturating_mul(60)\n                .saturating_add(length.as_secs());\n            let remaining_secs = end_of_timespan_secs.saturating_sub(MAX_WEEKLY_SECS);\n            Duration::from_secs(remaining_secs)\n        };\n        let chopped_len = length - remaining_len;\n\n        // There is always at least one window for any non-zero timespan.\n        utils::check_duration(&chopped_len)?;\n        let win1 = Self {\n            start_day,\n            start_hour,\n            start_minute,\n            length: chopped_len,\n        };\n        let mut windows = vec![win1];\n\n        // Remaining length (if any) is wrapped back to Monday start.\n        if remaining_len.as_secs() > 0 {\n            utils::check_duration(&remaining_len)?;\n            let win2 = Self {\n                start_day: chrono::Weekday::Mon,\n                start_hour: 0,\n                start_minute: 0,\n                length: remaining_len,\n            };\n            windows.push(win2);\n        }\n\n        Ok(windows)\n    }\n\n    /// Return window length, in minutes.\n    pub fn length_minutes(&self) -> u32 {\n        // SAFETY: invariant `length < MAX_WEEKLY_MINS < u32::MAX`\n        (self.length.as_secs() / 60) as u32\n    }\n\n    /// Return the weekly range covered by this window, in weekly minutes.\n    pub fn range_weekly_minutes(&self) -> Range<MinuteInWeek> {\n        // NOTE(lucab): Range in Rust does not include the upper limit, so\n        // this accounts for a +1 on the end value.\n        Range {\n            start: self.start_minutes(),\n            end: self.end_minutes().saturating_add(1),\n        }\n    }\n\n    /// Window start, in minutes since beginning of week.\n    fn start_minutes(&self) -> MinuteInWeek {\n        let minutes = u32::from(self.start_minute);\n        let hours = u32::from(self.start_hour).saturating_mul(60);\n        let days = self\n            .start_day\n            .num_days_from_monday()\n            .saturating_mul(24)\n            .saturating_mul(60);\n        days.saturating_add(hours).saturating_add(minutes)\n    }\n\n    /// Window end, in minutes since beginning of week.\n    fn end_minutes(&self) -> MinuteInWeek {\n        let start = self.start_minutes();\n        let length = self.length_minutes();\n        start.saturating_add(length)\n    }\n\n    /// Return whether datetime is contained in this window.\n    #[cfg(test)]\n    pub fn contains_datetime(&self, datetime: &DateTime<Utc>) -> bool {\n        let instant = utils::datetime_as_weekly_minute(datetime);\n        self.start_minutes() <= instant && instant <= self.end_minutes()\n    }\n}\n\nimpl Ord for WeeklyWindow {\n    fn cmp(&self, other: &Self) -> Ordering {\n        match self.start_minutes().cmp(&other.start_minutes()) {\n            Ordering::Equal => self.end_minutes().cmp(&other.end_minutes()),\n            cmp => cmp,\n        }\n    }\n}\n\nimpl PartialOrd for WeeklyWindow {\n    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {\n        Some(self.cmp(other))\n    }\n}\n\nimpl PartialEq for WeeklyWindow {\n    fn eq(&self, other: &Self) -> bool {\n        self.start_minutes() == other.start_minutes() && self.end_minutes() == other.end_minutes()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use chrono::Local;\n\n    #[test]\n    fn window_basic() {\n        let start_minutes = (2 * 24 * 60) + (6 * 60);\n        let end_minutes = start_minutes + 45;\n        let length = utils::check_minutes(45).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Wed, 6, 00, length).unwrap();\n        assert_eq!(windows.len(), 1);\n        assert_eq!(windows[0].length_minutes(), 45);\n        assert_eq!(windows[0].start_minutes(), start_minutes);\n        assert_eq!(windows[0].end_minutes(), end_minutes);\n    }\n\n    #[test]\n    fn window_split_timespan() {\n        let length = utils::check_minutes(60).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 45, length).unwrap();\n        assert_eq!(windows.len(), 2);\n        assert_eq!(windows[0].length_minutes(), 15);\n\n        assert_eq!(windows[1].length_minutes(), 45);\n        assert_eq!(windows[1].start_minutes(), 0);\n        assert_eq!(windows[1].end_minutes(), 45);\n    }\n\n    #[test]\n    fn calendar_basic() {\n        let length = utils::check_minutes(60).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 23, 45, length).unwrap();\n        assert_eq!(windows.len(), 1);\n        assert_eq!(windows[0].length_minutes(), 60);\n\n        let calendar = WeeklyCalendar::new(windows);\n        assert_eq!(calendar.windows.iter().count(), 1);\n    }\n\n    #[test]\n    fn window_contains_datetime() {\n        let length = utils::check_minutes(120).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 14, 30, length).unwrap();\n        assert_eq!(windows.len(), 1);\n        assert_eq!(windows[0].length_minutes(), 120);\n\n        let before_start = DateTime::parse_from_rfc3339(\"2019-06-24T14:29:59+00:00\").unwrap();\n        assert!(!windows[0].contains_datetime(&before_start.into()));\n\n        let start = DateTime::parse_from_rfc3339(\"2019-06-24T14:30:00+00:00\").unwrap();\n        assert!(windows[0].contains_datetime(&start.into()));\n\n        let after_start = DateTime::parse_from_rfc3339(\"2019-06-24T14:30:00+00:00\").unwrap();\n        assert!(windows[0].contains_datetime(&after_start.into()));\n\n        let before_end = DateTime::parse_from_rfc3339(\"2019-06-24T16:29:59+00:00\").unwrap();\n        assert!(windows[0].contains_datetime(&before_end.into()));\n\n        let end = DateTime::parse_from_rfc3339(\"2019-06-24T16:30:59+00:00\").unwrap();\n        assert!(windows[0].contains_datetime(&end.into()));\n\n        let after_end = DateTime::parse_from_rfc3339(\"2019-06-24T16:31:00+00:00\").unwrap();\n        assert!(!windows[0].contains_datetime(&after_end.into()));\n    }\n\n    #[test]\n    fn window_week_boundary() {\n        let length = utils::check_minutes(1).unwrap();\n        let single = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 59, length).unwrap();\n        assert_eq!(single.len(), 1);\n        assert_eq!(single[0].length_minutes(), 1);\n\n        let length = utils::check_minutes(2).unwrap();\n        let chopped = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 59, length).unwrap();\n        assert_eq!(chopped.len(), 2);\n        assert_eq!(chopped[0].length_minutes(), 1);\n        assert_eq!(chopped[1].length_minutes(), 1);\n    }\n\n    #[test]\n    fn calendar_contains_datetime() {\n        let length = utils::check_minutes(75).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Tue, 21, 0, length).unwrap();\n        assert_eq!(windows.len(), 1);\n        assert_eq!(windows[0].length_minutes(), 75);\n\n        let calendar = WeeklyCalendar::new(windows);\n        assert_eq!(calendar.windows.iter().count(), 1);\n\n        let datetime = Utc.with_ymd_and_hms(2019, 6, 25, 21, 10, 0).unwrap();\n        assert!(calendar.contains_datetime(&datetime));\n        // Sanity check that `WeeklyCalendar` is `TimeZone`-agnostic.\n        let datetime = Local.with_ymd_and_hms(2019, 6, 25, 21, 10, 0).unwrap();\n        assert!(calendar.contains_datetime(&datetime));\n    }\n\n    #[test]\n    fn calendar_whole_week() {\n        let length = utils::check_minutes(MAX_WEEKLY_MINS).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 0, 0, length).unwrap();\n        assert_eq!(windows.len(), 1);\n\n        let calendar = WeeklyCalendar::new(windows);\n        assert_eq!(calendar.windows.iter().count(), 1);\n        assert_eq!(calendar.total_length_minutes(), u64::from(MAX_WEEKLY_MINS));\n        assert_eq!(calendar.length_minutes(), u64::from(MAX_WEEKLY_MINS));\n\n        let datetime = chrono::Utc::now();\n        assert!(calendar.contains_datetime(&datetime));\n        let datetime = chrono::Local::now();\n        assert!(calendar.contains_datetime(&datetime));\n    }\n\n    #[test]\n    fn calendar_containing_window() {\n        let length = utils::check_minutes(75).unwrap();\n        let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Tue, 21, 0, length).unwrap();\n        assert_eq!(windows.len(), 1);\n        assert_eq!(windows[0].length_minutes(), 75);\n\n        let calendar = WeeklyCalendar::new(windows.clone());\n        assert_eq!(calendar.windows.iter().count(), 1);\n\n        let datetime = DateTime::parse_from_rfc3339(\"2019-06-25T21:10:00+00:00\").unwrap();\n        assert!(calendar.contains_datetime(&datetime));\n\n        let containing_windows = calendar.containing_windows(&datetime);\n        assert_eq!(containing_windows.len(), 1);\n        assert_eq!(containing_windows[0], &windows[0]);\n    }\n\n    #[test]\n    fn calendar_length() {\n        let l1 = utils::check_minutes(45).unwrap();\n        let mut w1 = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 1, 15, l1).unwrap();\n        assert_eq!(w1.len(), 1);\n        assert_eq!(w1[0].length_minutes(), 45);\n\n        let l2 = utils::check_minutes(120).unwrap();\n        let w2 = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 30, l2).unwrap();\n        assert_eq!(w2.len(), 2);\n        assert_eq!(w2[0].length_minutes(), 30);\n        assert_eq!(w2[1].length_minutes(), 90);\n\n        w1.extend(w2);\n        let calendar = WeeklyCalendar::new(w1);\n        assert_eq!(calendar.windows.iter().count(), 3);\n\n        assert_eq!(calendar.total_length_minutes(), 165);\n        assert_eq!(calendar.length_minutes(), 150);\n    }\n\n    #[test]\n    fn datetime_remaining() {\n        let length = utils::check_minutes(15).unwrap();\n        let w1 = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 1, 30, length).unwrap();\n        let calendar = WeeklyCalendar::new(w1);\n\n        let cases = vec![\n            (\"2020-11-23T00:15:00+00:00\", 60 + 15),\n            (\"2020-11-23T01:29:30+00:00\", 1),\n            (\"2020-11-23T01:30:00+00:00\", 0),\n            (\"2020-11-23T01:45:00+00:00\", 0),\n            (\"2020-11-23T02:00:00+00:00\", 60 * 24 * 7 - 120 + 90),\n            (\"2020-11-22T01:30:00+00:00\", 60 * 24),\n        ];\n        for (input, remaining) in cases {\n            let datetime = DateTime::parse_from_rfc3339(input).unwrap();\n            let output = calendar\n                .remaining_to_datetime(&datetime.into())\n                .unwrap()\n                .num_minutes();\n            assert_eq!(output, remaining, \"{}\", input);\n        }\n    }\n\n    #[test]\n    fn human_remaining() {\n        use chrono::Duration;\n\n        let cases = vec![\n            (0, \"now\"),\n            (1, \"in 1m\"),\n            (59, \"in 59m\"),\n            (60, \"in 1h 0m\"),\n            (61, \"in 1h 1m\"),\n            (120, \"in 2h 0m\"),\n            (1439, \"in 23h 59m\"),\n            (1440, \"in 1d 0h 0m\"),\n            (1441, \"in 1d 0h 1m\"),\n            (1501, \"in 1d 1h 1m\"),\n            (2879, \"in 1d 23h 59m\"),\n            (2880, \"in 2d 0h 0m\"),\n            (4503, \"in 3d 3h 3m\"),\n        ];\n\n        for (mins, human) in cases {\n            let remaining = Duration::minutes(mins);\n            let output = WeeklyCalendar::human_remaining_duration(&remaining).unwrap();\n            assert_eq!(output, human, \"{}\", mins);\n        }\n    }\n\n    #[test]\n    fn test_next_window_minute_in_week() {\n        use chrono::{NaiveDate, TimeZone};\n        use tzfile::Tz;\n\n        let l1 = utils::check_minutes(45).unwrap();\n        let mut w1 = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 1, 15, l1).unwrap();\n        let l2 = utils::check_minutes(30).unwrap();\n        let w2 = WeeklyWindow::parse_timespan(chrono::Weekday::Wed, 16, 00, l2).unwrap();\n        let l3 = utils::check_minutes(120).unwrap();\n        let w3 = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 00, l3).unwrap();\n        w1.extend(w2.clone());\n        w1.extend(w3.clone());\n        let calendar = WeeklyCalendar::new(w1.clone());\n\n        let tz = Tz::named(\"UTC\").unwrap();\n        let dt0 = (&tz).from_utc_datetime(\n            &NaiveDate::from_ymd_opt(2021, 4, 12)\n                .unwrap()\n                .and_hms_opt(0, 0, 0)\n                .unwrap(),\n        );\n        let dt1 = (&tz).from_utc_datetime(\n            &NaiveDate::from_ymd_opt(2021, 4, 12)\n                .unwrap()\n                .and_hms_opt(1, 5, 0)\n                .unwrap(),\n        );\n        let dt2 = (&tz).from_utc_datetime(\n            &NaiveDate::from_ymd_opt(2021, 4, 12)\n                .unwrap()\n                .and_hms_opt(2, 16, 0)\n                .unwrap(),\n        );\n        let dt3 = (&tz).from_utc_datetime(\n            &NaiveDate::from_ymd_opt(2021, 4, 16)\n                .unwrap()\n                .and_hms_opt(15, 14, 56)\n                .unwrap(),\n        );\n        let dt4 = (&tz).from_utc_datetime(\n            &NaiveDate::from_ymd_opt(2021, 4, 18)\n                .unwrap()\n                .and_hms_opt(23, 35, 00)\n                .unwrap(),\n        );\n\n        let cases = vec![\n            (\n                calendar.next_window_minute_in_week(&dt0),\n                Some(utils::datetime_as_weekly_minute(&dt0)),\n            ),\n            (\n                calendar.next_window_minute_in_week(&dt1),\n                Some(w1[0].range_weekly_minutes().start),\n            ),\n            (\n                calendar.next_window_minute_in_week(&dt2),\n                Some(w2[0].range_weekly_minutes().start),\n            ),\n            (\n                calendar.next_window_minute_in_week(&dt3),\n                Some(w3[0].range_weekly_minutes().start),\n            ),\n            (\n                calendar.next_window_minute_in_week(&dt4),\n                Some(utils::datetime_as_weekly_minute(&dt4)),\n            ),\n        ];\n\n        for (actual, expected) in cases {\n            assert_eq!(actual, expected);\n        }\n    }\n}\n"
  },
  {
    "path": "src/weekly/utils.rs",
    "content": "//! Utilities for weekly-time related logic.\n\nuse crate::weekly::{MinuteInWeek, MAX_WEEKLY_MINS, MAX_WEEKLY_SECS};\nuse anyhow::{anyhow, bail, ensure, Result};\nuse chrono::{DateTime, TimeZone, Weekday};\nuse fn_error_context::context;\nuse std::convert::TryInto;\nuse std::time::Duration;\n\n/// Convert `MinuteInWeek` to a week day and time.\npub(crate) fn weekly_minute_as_weekday_time(weekly_minute: MinuteInWeek) -> (Weekday, u8, u8) {\n    assert!(weekly_minute < MAX_WEEKLY_MINS);\n    let days_from_monday = weekly_minute / (60_u32).saturating_mul(24);\n    let weekday = match days_from_monday {\n        0 => Weekday::Mon,\n        1 => Weekday::Tue,\n        2 => Weekday::Wed,\n        3 => Weekday::Thu,\n        4 => Weekday::Fri,\n        5 => Weekday::Sat,\n        _ => Weekday::Sun,\n    };\n    let hour: u8 = (weekly_minute % (60_u32).saturating_mul(24) / 60)\n        .try_into()\n        .unwrap();\n    let minute: u8 = (weekly_minute % 60).try_into().unwrap();\n\n    (weekday, hour, minute)\n}\n\n/// Convert datetime to minutes since beginning of week.\npub(crate) fn datetime_as_weekly_minute(datetime: &DateTime<impl TimeZone>) -> MinuteInWeek {\n    use chrono::{Datelike, Timelike};\n\n    let weekday = datetime.weekday();\n    // SAFETY: hour() always <= 23.\n    let hour = datetime.hour() as u8;\n    // SAFETY: minutes() always <= 59.\n    let minute = datetime.minute() as u8;\n\n    time_as_weekly_minute(weekday, hour, minute)\n}\n\n/// Convert a point in weekly-time to minutes since beginning of week.\npub(crate) fn time_as_weekly_minute(day: chrono::Weekday, hour: u8, minute: u8) -> MinuteInWeek {\n    let hour_minutes = u32::from(hour.min(23)).saturating_mul(60);\n    let day_minutes = day\n        .num_days_from_monday()\n        .saturating_mul(24)\n        .saturating_mul(60);\n    let weekly_minute = day_minutes\n        .saturating_add(hour_minutes)\n        .saturating_add(u32::from(minute.min(59)));\n\n    assert!(weekly_minute < MAX_WEEKLY_MINS);\n    weekly_minute\n}\n\n/// Check duration for a sane lower and upper bound (whole week).\npub(crate) fn check_duration(length: &Duration) -> Result<()> {\n    if length.as_secs() > MAX_WEEKLY_SECS {\n        bail!(\"length longer than a week\")\n    };\n    if length.as_secs() == 0 {\n        bail!(\"zero-length duration\")\n    };\n\n    Ok(())\n}\n\n/// Parse a week day string (English names).\npub(crate) fn weekday_from_string(input: &str) -> Result<Weekday> {\n    let day = match input.to_lowercase().as_str() {\n        \"mon\" | \"monday\" => Weekday::Mon,\n        \"tue\" | \"tuesady\" => Weekday::Tue,\n        \"wed\" | \"wednesday\" => Weekday::Wed,\n        \"thu\" | \"thursday\" => Weekday::Thu,\n        \"fri\" | \"friday\" => Weekday::Fri,\n        \"sat\" | \"saturday\" => Weekday::Sat,\n        \"sun\" | \"sunday\" => Weekday::Sun,\n        _ => bail!(\"unrecognized week day: {}\", input),\n    };\n\n    Ok(day)\n}\n\n/// Parse a time string (in 24h format).\n///\n/// ## Example\n///\n/// ```rust\n/// let morning = time_from_string(\"6:20\").unwrap();\n/// assert_eq!(morning.0, 6);\n/// assert_eq!(morning.0, 20);\n///\n/// let afternoon = time_from_string(\"14:05\").unwrap();\n/// assert_eq!(morning.0, 14);\n/// assert_eq!(morning.0, 5);\n/// ```\n#[context(\"failed to parse time string\")]\npub(crate) fn time_from_string(input: &str) -> Result<(u8, u8)> {\n    let fields: Vec<_> = input.split(':').collect();\n    if fields.len() != 2 {\n        bail!(\"unrecognized time value: {}\", input);\n    }\n\n    let hour = fields[0]\n        .parse()\n        .map_err(|_| anyhow!(\"unrecognized time (hour) value: {}\", input))?;\n\n    let minute = fields[1]\n        .parse()\n        .map_err(|_| anyhow!(\"unrecognized time (minute) value: {}\", input))?;\n\n    ensure!(hour <= 23 && minute <= 59, \"invalid time: {}\", input);\n    Ok((hour, minute))\n}\n\n/// Validate a timespan (in minutes) and return its duration.\n#[cfg(test)]\npub(crate) fn check_minutes(minutes: u32) -> Result<Duration> {\n    let secs = u64::from(minutes).saturating_mul(60);\n    let length = Duration::from_secs(secs);\n    check_duration(&length)?;\n    Ok(length)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use proptest::prelude::*;\n\n    #[test]\n    fn test_check_duration() {\n        check_duration(&Duration::from_secs(u64::MIN)).unwrap_err();\n        check_duration(&Duration::from_secs(u64::MAX)).unwrap_err();\n\n        let length = Duration::from_secs(42 * 60);\n        check_duration(&length).unwrap();\n        assert_eq!(length.as_secs(), 42 * 60);\n\n        let max = check_minutes(crate::weekly::MAX_WEEKLY_MINS).unwrap();\n        assert_eq!(\n            max.as_secs(),\n            u64::from(crate::weekly::MAX_WEEKLY_MINS) * 60\n        );\n    }\n\n    #[test]\n    fn test_check_minutes() {\n        check_minutes(u32::MIN).unwrap_err();\n        check_minutes(u32::MAX).unwrap_err();\n\n        let length = check_minutes(42).unwrap();\n        assert_eq!(length.as_secs(), 42 * 60);\n\n        let max = check_minutes(crate::weekly::MAX_WEEKLY_MINS).unwrap();\n        assert_eq!(\n            max.as_secs(),\n            u64::from(crate::weekly::MAX_WEEKLY_MINS) * 60\n        );\n    }\n\n    #[test]\n    fn test_weekday_from_string() {\n        let mon1 = weekday_from_string(\"Mon\").unwrap();\n        assert_eq!(mon1, Weekday::Mon);\n\n        let mon1 = weekday_from_string(\"monday\").unwrap();\n        assert_eq!(mon1, Weekday::Mon);\n\n        weekday_from_string(\"domenica\").unwrap_err();\n    }\n\n    #[test]\n    fn test_time_from_string() {\n        let t1 = time_from_string(\"12:45\").unwrap();\n        assert_eq!(t1, (12, 45));\n\n        let t2 = time_from_string(\"07:5\").unwrap();\n        assert_eq!(t2, (7, 5));\n\n        time_from_string(\"0x0A:0o70\").unwrap_err();\n        time_from_string(\"-00:00\").unwrap_err();\n        time_from_string(\"25:00\").unwrap_err();\n        time_from_string(\"23:60\").unwrap_err();\n    }\n\n    #[test]\n    fn test_weekly_minute_as_weekday_time() {\n        let t = (24 * 60) * 2 + 60 * 4 + 5;\n        let weekday_time = weekly_minute_as_weekday_time(t);\n        assert_eq!((Weekday::Wed, 4, 5), weekday_time);\n        let t = 7;\n        let weekday_time = weekly_minute_as_weekday_time(t);\n        assert_eq!((Weekday::Mon, 0, 7), weekday_time);\n        let t = (24 * 60) * 6 + 60 * 23 + 59;\n        let weekday_time = weekly_minute_as_weekday_time(t);\n        assert_eq!((Weekday::Sun, 23, 59), weekday_time);\n    }\n\n    proptest! {\n        #[test]\n        fn proptest_time_from_string(time in any::<String>()){\n            time_from_string(&time).unwrap_or_default();\n        }\n\n        #[test]\n        fn proptest_weekday_from_string(day in any::<String>()){\n            weekday_from_string(&day).unwrap_or(Weekday::Sun);\n        }\n\n        #[test]\n        fn proptest_check_duration(length in any::<Duration>()){\n            let res = match check_duration(&length) {\n                Ok(_) => length,\n                Err(_) => Duration::from_secs(1),\n            };\n            prop_assert!(res.as_secs() > 0);\n            prop_assert!((res.as_secs() / 60) < u64::from(MAX_WEEKLY_MINS));\n        }\n\n        #[test]\n        fn proptest_time_as_weekly_minute(day in ..=6u8, hour: u8, minute: u8){\n            use num_traits::cast::FromPrimitive;\n\n            let weekday = Weekday::from_u8(day).unwrap_or(Weekday::Sun);\n            let res = time_as_weekly_minute(weekday, hour, minute);\n            prop_assert!(res < MAX_WEEKLY_MINS);\n        }\n    }\n}\n"
  },
  {
    "path": "tests/fixtures/00-config-sample.toml",
    "content": "[agent.timing]\nsteady_interval_secs = 35\n\n[identity]\ngroup = \"workers\"\nnode_uuid = \"27e3ac02af3946af995c9940e18b0cce\"\nrollout_wariness = 0.5\n\n[cincinnati]\nbase_url = \"http://cincinnati.example.com:80/\"\n\n[updates]\nallow_downgrade = true\nenabled = false\nstrategy = \"fleet_lock\"\n\n[updates.fleet_lock]\nbase_url = \"http://fleet-lock.example.com:8080/\"\n\n[updates.periodic]\ntime_zone = \"localtime\"\n\n[[updates.periodic.window]]\ndays = [ \"Sat\", \"Sun\" ]\nstart_time = \"23:00\"\nlength_minutes = 120\n\n[[updates.periodic.window]]\ndays = [ \"Wed\" ]\nstart_time = \"23:30\"\nlength_minutes = 25"
  },
  {
    "path": "tests/fixtures/20-periodic-sample.toml",
    "content": "[[updates.periodic.window]]\ndays = [ \"Tue\", \"Wed\" ]\nstart_time = \"23:00\"\nlength_minutes = 120\n\n[[updates.periodic.window]]\ndays = [ \"Thu\" ]\nstart_time = \"23:30\"\nlength_minutes = 25\n\n[[updates.periodic.window]]\ndays = [ \"Sun\" ]\nstart_time = \"00:00\"\n# 2 days, wrapping back to beginning of week.\nlength_minutes = 2880"
  },
  {
    "path": "tests/fixtures/30-periodic-sample-non-utc.toml",
    "content": "[updates]\nstrategy = \"periodic\"\n\n[updates.periodic]\ntime_zone = \"America/Toronto\"\n\n[[updates.periodic.window]]\ndays = [ \"Mon\" ]\nstart_time = \"00:00\"\nlength_minutes = 120"
  },
  {
    "path": "tests/fixtures/31-periodic-sample-non-utc.toml",
    "content": "[updates]\nstrategy = \"periodic\"\n\n[updates.periodic]\ntime_zone = \"localtime\"\n\n[[updates.periodic.window]]\ndays = [ \"Wed\" ]\nstart_time = \"23:00\"\nlength_minutes = 30"
  },
  {
    "path": "tests/fixtures/rpm-ostree-staged.json",
    "content": "{\n  \"deployments\" : [\n    {\n      \"unlocked\" : \"none\",\n      \"requested-local-packages\" : [],\n      \"base-commit-meta\" : {\n        \"ostree.manifest-digest\" : \"sha256:688fb50c9f19fecb638f660d2ad66fcafe47cb31d7a3cef98370dbde4d3315e9\",\n        \"ostree.manifest\" : \"{\\\"annotations\\\":{\\\"com.coreos.inputhash\\\":\\\"b9bfd62e8d2d57ac173ab175985ae7054bf7a59bdfd71766eb01a468978a16ba\\\",\\\"com.coreos.stream\\\":\\\"stable\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"org.opencontainers.image.base.digest\\\":\\\"sha256:984dcecf0a0e39f9eedace85c541b77f3d4a429da02321c62fb4bcfb7b7d46cb\\\",\\\"org.opencontainers.image.created\\\":\\\"2026-01-02T19:17:54.376774018Z\\\",\\\"ostree.commit\\\":\\\"1253cc5c04bb8229a25fd53c51faa1815e2657789884e57d347591f5b2d7821b\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"},\\\"config\\\":{\\\"digest\\\":\\\"sha256:f9bd0c5cfafde224577140ca4e7102d37786f429f0992d16a03ab829d8119d53\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.config.v1+json\\\",\\\"size\\\":12967},\\\"layers\\\":[{\\\"digest\\\":\\\"sha256:59bcce7c67469f1ad877c8860b3a7d63487580b69c6fe4aab2ef9cb516564c9e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1615741},{\\\"digest\\\":\\\"sha256:2155f299c46d924e1884dd9ef8ea595a96184ca3b8fd616e9995961499584ebe\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":42116092},{\\\"digest\\\":\\\"sha256:cf4e7c2b447b75018e9a16243717ed8e4f9d24b37c92db6be08872bf5890f737\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":106025948},{\\\"digest\\\":\\\"sha256:183f3b51c668b2e5451f7619cfad0949668788eca1ec1047bd4353579127bd30\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":102878010},{\\\"digest\\\":\\\"sha256:a419cfd8c8ae2f4cd69985d80815a4ddd62b9a09fd770a546217812e1390365f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":30390918},{\\\"digest\\\":\\\"sha256:896120a78cf78fb8168fc4386c92066fd17350a9143415a3ebda90d2167bd314\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":35418942},{\\\"digest\\\":\\\"sha256:296015a90eea599092033a047b10875984aada6ee607d681e0136b70d4d8602d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":73987237},{\\\"digest\\\":\\\"sha256:8fe7c5f0dbda5e4e43dcae6876a5ad7265bf90705438e5124ef34e6222ee42d2\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":20116607},{\\\"digest\\\":\\\"sha256:7acef8a4b1f68c86fd0acc7af9e16c1d064bfc388b4b364882b22b54742a1f73\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":44248428},{\\\"digest\\\":\\\"sha256:95fa74b5ba2deed54d0fc4ebd0cfaf2058a1463b7fa310be87ef35740a29b512\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":14033951},{\\\"digest\\\":\\\"sha256:a5cfdf2ae03931ab1cfffdd407baf39b2f94f9e5c0d878224a00fd6d5dd326bf\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":16332825},{\\\"digest\\\":\\\"sha256:49e3051853e57d23d53fe209d4cecd9839511420f6917629657fff0f5edca155\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10120473},{\\\"digest\\\":\\\"sha256:73b2e3bbbefc27f4845048567b364eb0c033ef40ac26b57f1f09838922b82852\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":19784134},{\\\"digest\\\":\\\"sha256:a0c9fb67a149ddb050e7f4431ee4770e03d6aa8027ed5c50d7133d5a739315e5\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10634403},{\\\"digest\\\":\\\"sha256:e20cfeab2182aa8e4a0450499050f504f5807cd591df1e3f2415033209c9fedb\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":27588290},{\\\"digest\\\":\\\"sha256:5d593e1e6d5aec1bed94ce451221164540ba563be9f3782265e9cffa16242185\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10230020},{\\\"digest\\\":\\\"sha256:8b2478dc97c99a0a86c6627f609ecb4773f5333e964eb6d4cf2b8628dc58ba6b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10837709},{\\\"digest\\\":\\\"sha256:05b6ad3317a81f209e13e03ff5ed081f97b9f03f94086e6f45588e8daffcdde3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":7988700},{\\\"digest\\\":\\\"sha256:23277e272f03864611273ca5185e59cd593fe074ea65761bdc7b59c4430dbb1e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":6618536},{\\\"digest\\\":\\\"sha256:3749677fd60bdf9ea35e2b0e8ccfe9598ec84703bbfd5f69b6061b8ad9f24af0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":5221769},{\\\"digest\\\":\\\"sha256:1f51c2d6f0f83f3e19fa49bda791297271d2850f8553d75a62f29cf1ea6b298c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":14145184},{\\\"digest\\\":\\\"sha256:d26dc9f1515497b11e4bb52ab34ff988173e00950580992826da915c0fca9606\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":6219212},{\\\"digest\\\":\\\"sha256:be7dbf2c0d5d8d5b4b0839dfbea3e2d3bb53d3b4e5f676e11ab07e8c691b446c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4947509},{\\\"digest\\\":\\\"sha256:af6d21175846a97e6ca61fe2cb58745ca76d14d415f9719aa3746baec4645039\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3158092},{\\\"digest\\\":\\\"sha256:0aa72dca2b267205f71c8b2658a95b3f9d3f3a3a1d3b3967d2c7fbc21436547d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4705205},{\\\"digest\\\":\\\"sha256:bedd43c25b24b84f45d562da06d7a3e181fae75e479173399727d503ac2f5c13\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1175698},{\\\"digest\\\":\\\"sha256:bd5f98da59925ad468779c7ddcd4d1cb7a483ad8f61f5b1d51d59aaf71218038\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4846733},{\\\"digest\\\":\\\"sha256:988822765bc44c714eec70c6213705a666d82281a5c93e2dd06a79b8a1ecb5df\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4287568},{\\\"digest\\\":\\\"sha256:70b9b31e219030795f6667838d2d9a91009cba86461114b8fa2c3d28d66b8ce5\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3751451},{\\\"digest\\\":\\\"sha256:abd90dddf969bcaedbe775c35d1b10dc6b08e9924e1dd87abe6280211688880b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3490052},{\\\"digest\\\":\\\"sha256:54db5d7db7c9f167c3f4f72564a94437adac2f0792ac79d1d919408272787ffa\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4630016},{\\\"digest\\\":\\\"sha256:9fea358785c59a8cc852b633879966e33bf4ec5a3d65a2a9e1f961a7bbf100da\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4029320},{\\\"digest\\\":\\\"sha256:497de001e846a097021717511d4264b57609d2ea070feb8591a60f890acb298a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2610055},{\\\"digest\\\":\\\"sha256:3d6c086475fa1c715e299a7f858efd1ef9e59c6789d8a705f9c5ea76bad8f332\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10009999},{\\\"digest\\\":\\\"sha256:66247684117bb6db41e4c601be609ae514af5785721bc0407d6af27bc859b457\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4267829},{\\\"digest\\\":\\\"sha256:99056e7fd82cce788ce8a86f76885ddc34d936df4b47dc7646589656d107e0ef\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3947880},{\\\"digest\\\":\\\"sha256:569d49efd33a4704a59b2791c88cc1fe47591bd6bb14ff4c1df8f42996fea166\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":9437794},{\\\"digest\\\":\\\"sha256:deddb363996b42fdde78cd91e6eb0c43625b9b7cee06e3b76bb243e63d64c6ca\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3331333},{\\\"digest\\\":\\\"sha256:414f4119a2272df7eaa701260c83f339a4072789164e928e255e9de597947878\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2348658},{\\\"digest\\\":\\\"sha256:15835afa2fe667521172797d59e43edc5c6c98a6200956193baa1996366dce4e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":730796},{\\\"digest\\\":\\\"sha256:d1b7b057ecf708c465c4f19597de3629a5f22011ec6c7f8ecf74fc85aa18d0d3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3800405},{\\\"digest\\\":\\\"sha256:24fab248046796e8c32dcee2c70589120baad5254477199b5403656d9e14c400\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":27637515},{\\\"digest\\\":\\\"sha256:7299f6d305d7fab32500bfed46fd4bf4346711e81c00115d1d681759604f9381\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2697840},{\\\"digest\\\":\\\"sha256:8c3543286fe3022faab9fb70a208ce474a02066408e2465777a8acfbb6585aa0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1026120},{\\\"digest\\\":\\\"sha256:7c55737ca8c0ed660ec158ede622928404cd68f6b64767c1a77005a9bf8f2bfe\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2264268},{\\\"digest\\\":\\\"sha256:917f58ee63b3479906f41bcf82541be23c3593f1e949485d5a0c4e6848154d52\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2615923},{\\\"digest\\\":\\\"sha256:fc967d5b284fae0795e6a3d51ce7d407280a6f047c8b5ef0e9bd0933b9f27793\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1265850},{\\\"digest\\\":\\\"sha256:f6cc01d1bce768a02bc6a76498251005d5f761d36159235b44c343fbe7906b58\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1485191},{\\\"digest\\\":\\\"sha256:d33696fe05e9ce81fbefaf7971b79ab8d6ad3e9b7ccff7d36a3eebb91ede2c77\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1822577},{\\\"digest\\\":\\\"sha256:ad5e2c8cf9c07c8c6ba5b650ed2e6cbb461bb6f74adb0be0499d09a7bc306ce8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2519964},{\\\"digest\\\":\\\"sha256:c2f985beaa7e79e9ede33acf6f029fb7d3b7593647c750d6a6d6b5b1034d0883\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":762031},{\\\"digest\\\":\\\"sha256:fe6ffc4acef58c5fd5f0f84ed1d41d0be265191cca2b4440d2f5a9e77ae9d22c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":11061622},{\\\"digest\\\":\\\"sha256:aec38239af7862fab32eb8f3870735c2bb164aa5b720755dac97e3c59de8e63e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":7921674},{\\\"digest\\\":\\\"sha256:c7dc7b4c7373a31764bc639fa2b4e1aa57f7c25d632edeaf6a26bca512b03a08\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":9894119},{\\\"digest\\\":\\\"sha256:bc2c8a1de52287def116f4a4333f8e12c226b187f55b3cd31afa8cf363cf0ee6\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":8559341},{\\\"digest\\\":\\\"sha256:f40fbde1d0346af943994cd4e0132d6e6112d2b2e132a7da46271c8dc489114a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":18448323},{\\\"digest\\\":\\\"sha256:ba6840f27022c32a26df198371d389a44c10ddb6f1ad959a5aa4681c75b535f1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3234473},{\\\"digest\\\":\\\"sha256:d4412327b15af6da1a8648274d8ef9a4780788bf6cceda4c3456d107d41752c8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":358856},{\\\"digest\\\":\\\"sha256:82b4ad04836f34f50d0e4be16943b6a43543760e6368a8bffea6499424236bf0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2647039},{\\\"digest\\\":\\\"sha256:5b19d03cb5bb3eb16ad8237ae4b9c1dc971fa2dc3599f5ccd2965b4c0468734f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4089539},{\\\"digest\\\":\\\"sha256:43794c4f657394c47275e04afb171546ce2455a68d6ce868f5475a0e8182fda9\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4892832},{\\\"digest\\\":\\\"sha256:199003d9e74b3d68425cf40ba9f3ef12ebf7acb727d86f54154fd8b2ea940691\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1850250},{\\\"digest\\\":\\\"sha256:aa921388e9ba5412b4489c74e7a3fa0ca1afc2b677c80718974447e24fb177e0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":18205616},{\\\"digest\\\":\\\"sha256:3bb3e7b5f5a5e781b9aaa08a0f266090fb68cdffc071ac10e1f9f02c205b1a6d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":141190429},{\\\"digest\\\":\\\"sha256:ad312c5c40ccd18a3c639cc139211f3c4284e568b69b2e748027cee057986fe0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2331},{\\\"digest\\\":\\\"sha256:fb23d80c790687f99e27121ef32d815b6b7b6a9d1b0e2eadc15f93bd5d17b4f1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":178}],\\\"mediaType\\\":\\\"application/vnd.oci.image.manifest.v1+json\\\",\\\"schemaVersion\\\":2}\",\n        \"ostree.container.image-config\" : \"{\\\"architecture\\\":\\\"amd64\\\",\\\"config\\\":{\\\"Cmd\\\":[\\\"/sbin/init\\\"],\\\"Env\\\":[\\\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\\\"],\\\"Labels\\\":{\\\"com.coreos.inputhash\\\":\\\"b9bfd62e8d2d57ac173ab175985ae7054bf7a59bdfd71766eb01a468978a16ba\\\",\\\"com.coreos.osname\\\":\\\"fedora-coreos\\\",\\\"com.coreos.stream\\\":\\\"stable\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"io.buildah.version\\\":\\\"1.42.2\\\",\\\"org.opencontainers.image.description\\\":\\\"Fedora CoreOS stable\\\",\\\"org.opencontainers.image.revision\\\":\\\"8b5ac6dc1ea389c125e2b7c1afaca232db121973\\\",\\\"org.opencontainers.image.source\\\":\\\"https://github.com/coreos/fedora-coreos-config\\\",\\\"org.opencontainers.image.title\\\":\\\"Fedora CoreOS stable\\\",\\\"org.opencontainers.image.version\\\":\\\"43.20251214.3.0\\\",\\\"ostree.bootable\\\":\\\"1\\\",\\\"ostree.commit\\\":\\\"1253cc5c04bb8229a25fd53c51faa1815e2657789884e57d347591f5b2d7821b\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"},\\\"StopSignal\\\":\\\"SIGRTMIN+3\\\"},\\\"created\\\":\\\"2026-01-02T19:17:54.376774018Z\\\",\\\"history\\\":[{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"ostree export of commit 1253cc5c04bb8229a25fd53c51faa1815e2657789884e57d347591f5b2d7821b\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"llvm18-libs-18.1.8-6.fc42.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"nvidia-gpu-firmware-20251125-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"kernel-modules-6.17.11-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"moby-engine-29.0.4-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"containerd-2.1.5-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"kernel-modules-core-6.17.11-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"podman-5:5.7.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"linux-firmware-20251125-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"ignition-2.24.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"libicu-77.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"rpm-6.0.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"kernel-core-6.17.11-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"docker-cli-29.0.4-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"amd-gpu-firmware-20251125-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"skopeo-1:1.21.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"git-core-2.52.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"samba-client-libs-2:4.23.4-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"rpm-ostree-2025.12-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"glib2-2.86.3-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"microcode_ctl-2:2.1-71.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"nmstate-2.2.55-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"runc-2:1.4.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"systemd-udev-258.2-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"toolbox-0.3-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"file-libs-5.46-8.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"coreos-installer-0.25.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"systemd-258.2-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"coreutils-common-9.7-6.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"NetworkManager-libnm-1:1.54.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"bootc-1.10.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"fwupd-2.0.18-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"hwdata-0.402-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"qed-firmware-20251125-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"netavark-2:1.17.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"openssl-libs-1:3.5.4-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"intel-gpu-firmware-20251125-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"148 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"systemd-shared-258.2-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"systemd-resolved-258.2-1.fc43.x86_64 and libldb-2:4.23.4-1.fc43.x86_64 and samba-common-libs-2:4.23.4-1.fc43.x86_64 and libsmbclient-2:4.23.4-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"vim-minimal-2:9.1.1972-1.fc43.x86_64 and systemd-libs-258.2-1.fc43.x86_64 and systemd-container-258.2-1.fc43.x86_64 and systemd-pam-258.2-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"9 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"libgcc-15.2.1-4.fc43.x86_64 and bind-utils-32:9.18.41-2.fc43.x86_64 and libdnf5-cli-5.2.17.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"7 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"8 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"elfutils-libelf-0.194-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"13 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"initramfs (kernel 6.17.11-300.fc43.x86_64) and rpmostree-unpackaged-content\\\"},{\\\"created\\\":\\\"2026-01-02T19:15:06Z\\\",\\\"created_by\\\":\\\"Reserved for new packages\\\"},{\\\"comment\\\":\\\"FROM oci-archive\\\",\\\"created\\\":\\\"2026-01-02T19:17:45.887854481Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2026-01-02T19:17:45.942146944Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG NAME VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2026-01-02T19:17:45.995043383Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG DESCRIPTION NAME VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2026-01-02T19:17:54.103773862Z\\\",\\\"created_by\\\":\\\"|5 DESCRIPTION=Fedora CoreOS stable NAME=fedora-coreos VERSION=43.20251214.3.0 /bin/sh -c --mount=type=bind,from=builder,target=/var/tmp     --mount=type=bind,target=/run/src,rw       rm /run/src/out.ociarchive:sha256:aae0eae6d55fb4717ad9bf6a3e9bc9c7f7da9637de849110e1c50bebc2e891b7\\\"},{\\\"created\\\":\\\"2026-01-02T19:17:54.189051389Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) LABEL containers.bootc=1       ostree.bootable=1       org.opencontainers.image.version=$VERSION       com.coreos.osname=$NAME       org.opencontainers.image.title=$DESCRIPTION       org.opencontainers.image.description=$DESCRIPTION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2026-01-02T19:17:54.259826785Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) STOPSIGNAL SIGRTMIN+3\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2026-01-02T19:17:54.320407625Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) CMD [\\\\\\\"/sbin/init\\\\\\\"]\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2026-01-02T19:17:54.378635646Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) LABEL \\\\\\\"org.opencontainers.image.source\\\\\\\"=\\\\\\\"https://github.com/coreos/fedora-coreos-config\\\\\\\" \\\\\\\"org.opencontainers.image.revision\\\\\\\"=\\\\\\\"8b5ac6dc1ea389c125e2b7c1afaca232db121973\\\\\\\" \\\\\\\"fedora-coreos.stream\\\\\\\"=\\\\\\\"stable\\\\\\\"fedora-coreos.stream=stable\\\",\\\"empty_layer\\\":true}],\\\"os\\\":\\\"linux\\\",\\\"rootfs\\\":{\\\"diff_ids\\\":[\\\"sha256:49df1a2f3a3887707cc5bef88305016dec8f225cb91c763e99aca194567fe148\\\",\\\"sha256:bfc577b33ac1fa0d676ea3ad783428f4957512c62ba2ea382ee49a2c885c796e\\\",\\\"sha256:c9e89284d5a5b43542dcb7ff881ffd68c724a0b41be9640c8862d0f1da769751\\\",\\\"sha256:7f76c65a949b2576156279fa96166d171a9529a53091c84a366c738fb4d5f198\\\",\\\"sha256:b88f9d372f5d780e4fc72f997957362dd9afa952396867a96f9fbb69b4114879\\\",\\\"sha256:2735a383960f3179eebb602035d1a28c88be7605d5cf155e8bf26314d22dded8\\\",\\\"sha256:173b87d523b983aac9d3ba3f87860b1ea42229d8cd346285f5c8cc1bb764cc9f\\\",\\\"sha256:52fb483d9d145f0828389c57761f13b63aeed1efde07f60732f7d53edb41f7f5\\\",\\\"sha256:2a0d6fd10a4eb968cfadd2daaf55fae03dea92b8da3d3fb7cf8c5acff6b9ad24\\\",\\\"sha256:5de766e62023794cb7f14714c830e758f9d944b5435edb63f593d441754012b7\\\",\\\"sha256:5d773fe7a1673b09033d50078a0d08e2a05f36755124644b359ebd8c9c4b3133\\\",\\\"sha256:407417ae5a615278b9bbb1f725e695f7e889f474b1978ce0079366a6fadb5833\\\",\\\"sha256:530630f279551934d785033879978255f861df7178e42061bc569204ce85bf1b\\\",\\\"sha256:2713e0a51e5f93db62fbadfaf032c6adfe85ad9f60b23ddc18971d3ba13b292f\\\",\\\"sha256:e6a1d54ebb933eaa0cf4eefa0e3d0c70284b6358f39ff551af50e6e501856e8b\\\",\\\"sha256:915dc4c76e1cbadbd9ccb6fd52f58e352dc4ed50aee570fa309403050caadce4\\\",\\\"sha256:416642140f156411a8f98a7f98a3f24d15c5042bfcda51aba73253b048854c22\\\",\\\"sha256:5ac73839f135a41f82e2b18902f751ccbc4312fe7af8c026663e0c952d4bf94b\\\",\\\"sha256:ab035011e3cbe322bbec6e73b14fd7ed497d14477fcf2e3277887ec7523874c2\\\",\\\"sha256:f3857bc5fc97859cfefd5e0369c1d39856a5e5036f915fb4aead93497a616b24\\\",\\\"sha256:43fbd08d2370620e912857d973d0700ce8f18eb91e64503967b7066aebe87714\\\",\\\"sha256:310d0dfde48544ee21b32c1fd23ea221005fbb110851cf0f1540f1d0335e0a6b\\\",\\\"sha256:0d6bd17b418dab41df403bc131c7e1ef4e632b09f4852aa7f4ffb9f2d9bfa0dd\\\",\\\"sha256:3b5ebea527ca91555c5660ef099f4ae921ab573cbf1000d910790fe2c6830533\\\",\\\"sha256:767808746e25a4e6b37f05a23ffb6ac295863e49a54b74b9b9933761558b7e45\\\",\\\"sha256:b0aed4a5af11fa66890314fadcf2ba6ff4853798134c2fbb4a641729c5318ad2\\\",\\\"sha256:e92a509bf055ac1e2c3717272fdc0a39697d5b9cf25eb593c431e21745e90914\\\",\\\"sha256:ad791e94d3003fae09ae4b8894dcc7da2c6c63719d9b5ceb4a63469e63a434cc\\\",\\\"sha256:ceabf9ebbaf3772a6e6113531114e5b90fdccf1cd575bc4da9ea2588f833a85b\\\",\\\"sha256:ea14e896bb605065dff10dcd0b46aa67c6362787c63df678d141776bd82e52ca\\\",\\\"sha256:0b5914dd19f97b08ba2434d7cf0173ab08e846eba83e77b6ccc228f0f773180f\\\",\\\"sha256:58e2146937a2d548b25ccd896cf406b43da7eeb694cdb00e0a81c680aa83ab11\\\",\\\"sha256:904a0bca0cd116151c54bda8b710da3ab5af3d1ee450d71bf8e3fd6eecd4056e\\\",\\\"sha256:6444f6c4120db7bdf2f8295571c036e47885992f85879c36d7bd54789846798d\\\",\\\"sha256:3c5ba580d19492457cca59e4e4bd341882da77f975a1d46cfd5c8c5f9b044bf9\\\",\\\"sha256:0db5c7d31d7178dd7d8e7bc0a738153b8a127fc268cd2435eb7dc36aea0d09ff\\\",\\\"sha256:7a01e08086eddcde7e0225ddeedffb76a487375577bc0d17f83cf68bf9794f61\\\",\\\"sha256:588abd9e6198f4a38641ea20258ab8563f711b2480d44ea6265c3f60a774c4b9\\\",\\\"sha256:8a3b9eed713e841b2d89041a87266ae0b942dee6de1a5b70d974be368b7bc2ae\\\",\\\"sha256:7fe247f5255f836f1c637a55a5de3ec213a9157eec4b0b18bf43dd57324e2f36\\\",\\\"sha256:8e5a714469a3e282d49a9ce02966da756c8fa27e4482e8831b96b8ca526bc2c4\\\",\\\"sha256:09315d8354e3667bba73325b47fc286294842970f84d0e600e896f7550d16905\\\",\\\"sha256:ee3625734c51bc0d7ed7a5c13b5bb3d7dc92de60c339f4fbb966584ce7e73f27\\\",\\\"sha256:9960d2fdc5bdeaa7ee9d5354f10deff42a0c39bdf8a09594f11d8424192185f5\\\",\\\"sha256:08b421ab1a244785f6c8f8515df3aa3c56b92071154760feeef0a8cb2e9dc0d1\\\",\\\"sha256:bcd2e142c455c9dbc496a2818f8ba5228846b318806676911d565f7b06f91908\\\",\\\"sha256:10cf6a10b29e5c2327e2c11abee80c4b172674c589040d5d618bfa87d00c3b3f\\\",\\\"sha256:5d5f8f31bafa645842029de5e0f82f3b8fbfa888c07276b5f57548798da1023e\\\",\\\"sha256:a9a8ceee0f805f620818aa421674fd3e474b4300e09bc24dfe5de60f7c448330\\\",\\\"sha256:6c1c46435ddea40b1aae877018aed2780cb96010e9a19659c9b0a11e1e882276\\\",\\\"sha256:0cfd0e6497e027292065d38bbbc7c262107133bbd4c6fdbab874dbeb832960dd\\\",\\\"sha256:d70a2d2b5adbb0a058bb0874f4e5e1ae7fe045b3a4a3e8b43e646905a48f1ad7\\\",\\\"sha256:36b9a09a97543fb61365a217ee28eb0c1b1aefb06e659a03e1cbac33896c8c10\\\",\\\"sha256:205bd33b3df37a2fec7690e30cc6c48c73aa583a5497eb89b4c405439687977b\\\",\\\"sha256:a82e866592c673f31f32d26e802637eb6d8853d8416ac4ace7eb20962b77e82f\\\",\\\"sha256:01304f6c6f0816f2cf4ddc5aad98463ac877f4532a64b25ccfde3f6e442d11f5\\\",\\\"sha256:08e94261fc2363206f51aa7f366a04442e1a89e7147f356bf458c6fac376edc9\\\",\\\"sha256:e3dd84f9da1d16d1926252d75e2b402de40c5a4ee9576cae30813d1b9eb82958\\\",\\\"sha256:9a94becf2734297a502a52eb1070f07328312872856e8efd935ec5358847523b\\\",\\\"sha256:a5fbf451f2328fe3b3fb3bd7a4cde7a98b538feaabacd2c699801a2c7e8b4986\\\",\\\"sha256:baef0428ebf9a871fef1f35ab106543c5eab6f0e8ff6e1d2884c5f17909bd30d\\\",\\\"sha256:6ffd2627cf25c620c95fd3dd45d4d8da00fa2104467351928b1c0baee4acd0be\\\",\\\"sha256:c50553d0af8de497a8ff0c32ed05c71b83c3feef43b42a572bb8fa1ef7adb4d1\\\",\\\"sha256:0c991be4943c276dd63157da68f27283962e628b05fec0d079d56df1c004769d\\\",\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"sha256:077e715c04cca00d9690526d16f25c65d134c9892a1c966c72e884d51df2792c\\\"],\\\"type\\\":\\\"layers\\\"}}\",\n        \"ostree.importer.version\" : \"0.15.3\"\n      },\n      \"base-removals\" : [],\n      \"pinned\" : false,\n      \"osname\" : \"fedora-coreos\",\n      \"base-remote-replacements\" : {      },\n      \"regenerate-initramfs\" : false,\n      \"checksum\" : \"5ff49ca469b1ec8efc922c5e972c166f072e2535c582af62891f74360c70ad84\",\n      \"container-image-reference-digest\" : \"sha256:688fb50c9f19fecb638f660d2ad66fcafe47cb31d7a3cef98370dbde4d3315e9\",\n      \"requested-base-local-replacements\" : [],\n      \"id\" : \"fedora-coreos-5ff49ca469b1ec8efc922c5e972c166f072e2535c582af62891f74360c70ad84.0\",\n      \"version\" : \"43.20251214.3.0\",\n      \"requested-local-fileoverride-packages\" : [],\n      \"requested-base-removals\" : [],\n      \"requested-packages\" : [],\n      \"serial\" : 0,\n      \"timestamp\" : 1767381474,\n      \"staged\" : false,\n      \"booted\" : true,\n      \"container-image-reference\" : \"ostree-image-signed:docker://quay.io/fedora/fedora-coreos:stable\",\n      \"packages\" : [],\n      \"base-local-replacements\" : []\n    },\n    {\n      \"unlocked\" : \"none\",\n      \"requested-local-packages\" : [],\n      \"base-commit-meta\" : {\n        \"ostree.manifest-digest\" : \"sha256:1693b47dfccebdde19e81c3d0a0392010f0ec67e827f096d1b3f8aec662eb5cf\",\n        \"ostree.manifest\" : \"{\\\"annotations\\\":{\\\"com.coreos.osname\\\":\\\"fedora-coreos\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"coreos-assembler.image-config-checksum\\\":\\\"6495271cd8f2df9041e93dd10ee0c267133d9be20ec69df12c86e554d65d5929\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"org.opencontainers.image.revision\\\":\\\"70e8ceed13d40be5df3cbf717c69a0212b1c4d22\\\",\\\"org.opencontainers.image.source\\\":\\\"https://github.com/coreos/fedora-coreos-config\\\",\\\"org.opencontainers.image.version\\\":\\\"42.20251012.3.0\\\",\\\"ostree.bootable\\\":\\\"true\\\",\\\"ostree.commit\\\":\\\"5e8106b13057e32cb23afc13db1eac5f9d59498679b7aada5da039a86ce9c185\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"ostree.linux\\\":\\\"6.16.10-200.fc42.x86_64\\\",\\\"rpmostree.inputhash\\\":\\\"9df5ffcc6dd2e74776a94fe06d32817fca048b13f75458f455ae5bea7311fa37\\\"},\\\"config\\\":{\\\"digest\\\":\\\"sha256:1eb2557272e25dbe30f60a8fdfd6a8817fa705c75243bfc7561d45628eee49f3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.config.v1+json\\\",\\\"size\\\":9059},\\\"layers\\\":[{\\\"digest\\\":\\\"sha256:f6c7431128a7aa8df5a3fb47e013db4b44159955d40a8b597668b8cd8928513c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1580153},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"llvm18-libs\\\"},\\\"digest\\\":\\\"sha256:aab5ce09aecad74a10ca3002622fbde68a1fd068fc08b79bd8035aaaf3a6b980\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":40581841},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"nvidia-gpu-firmware\\\"},\\\"digest\\\":\\\"sha256:192fefea68d06ffbd2ad57402b700d0b293769687d5a07445e35a5079155edc4\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":105978439},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"containerd\\\"},\\\"digest\\\":\\\"sha256:3b81a3dbd70ecccbf8e0b5c6a592b6e32437320b5c216c630786ef0bf57e4f09\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":37804337},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"kernel-modules\\\"},\\\"digest\\\":\\\"sha256:531185c4b439c4fca21418fae19d603faa568c6b3584c92c205d0f93bf30cdb0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":100087374},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"moby-engine\\\"},\\\"digest\\\":\\\"sha256:b76ed3b619614796f27eeb8ddd62a5273346bf8be8e2c6d0a7c95594a23d2a83\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":28469492},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"kernel-modules-core\\\"},\\\"digest\\\":\\\"sha256:0a807a4386abda6881e9c7858bbddc9d6beb9b54c11537f59a1c014390b5467e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":72592107},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"podman\\\"},\\\"digest\\\":\\\"sha256:673377e8821ce099e1f326a783ceb2ade5ca95b83cdedd4004b4912121475fa3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":19133208},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"ignition\\\"},\\\"digest\\\":\\\"sha256:5623f801e6989a8169aa4cc7eeb447be5a94e33655c65f807348d749648a9c5d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":13375370},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"linux-firmware\\\"},\\\"digest\\\":\\\"sha256:a19943f631f1df08169c64e3566a818feaf8968e5dceaba03a24ecb7d65657cf\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":42740400},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"libicu\\\"},\\\"digest\\\":\\\"sha256:f29802b0738633ca380855b417ce0f9cd7a3e0aca386857acd5c73fd04d79fc9\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":15655355},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"rpm\\\"},\\\"digest\\\":\\\"sha256:d8d45938412267840af6ff9bc8bbb12159e9229542d40a8813f0f9139d8ff684\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":9909890},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"kernel-core\\\"},\\\"digest\\\":\\\"sha256:10810a0275b98af0bfc53615745a8bd30f6ad8e73e3af1db9bac57c48e8a2145\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":19599844},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"docker-cli\\\"},\\\"digest\\\":\\\"sha256:9b478f9def4ad1b801618bdef3bfa62f138c1dc3f49b9870624260564ddfd77f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10576115},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"amd-gpu-firmware\\\"},\\\"digest\\\":\\\"sha256:f418ac25e550fd43d1e326bcf651b127cdc955c3495125a7eeffd0c5b4bc55bd\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":27151107},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"skopeo\\\"},\\\"digest\\\":\\\"sha256:dc1b83e68bddcc28d05a2b79bafd14db18ea220377805d86c78330362b5a7915\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10007184},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"git-core\\\"},\\\"digest\\\":\\\"sha256:1eaa0ea7699c1f12736899ea5cf09ffeb3d1576c25fb984784ff27217adfc022\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10318532},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"samba-client-libs\\\"},\\\"digest\\\":\\\"sha256:5f4a48c45fd4d9c2ba978f3614565dd79facc1bd4badbb3cb72418f62c0618d5\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":7545042},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"rpm-ostree\\\"},\\\"digest\\\":\\\"sha256:9083a1a3cee665349bfe5ddfad08c618dad87a2ca36afda2a4c41e9d142187ca\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":6379749},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"glib2\\\"},\\\"digest\\\":\\\"sha256:35d6934baf9cc2f66c541437e3ab7ae5fa5b73ab3bca622b5e0b1b06ac9ec9fe\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":5022048},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"microcode_ctl\\\"},\\\"digest\\\":\\\"sha256:6b74e081e2f991b66aed49f42dc75503d0bf2203eceb10611898df4336d24d36\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":14022696},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"nmstate\\\"},\\\"digest\\\":\\\"sha256:bb7c11b8f9aa9989e33758bfb4dddba3480472654f5bcc7c5127012b894387c3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":5924096},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"runc\\\"},\\\"digest\\\":\\\"sha256:93a1ad18092b6e7e514455fcfe5e0c91f7291132f4ca84efb39b6c7754dcc57a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4643154},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"systemd-udev\\\"},\\\"digest\\\":\\\"sha256:ced01b22d1903cbdc3814dace497019f535916935b2dce60f6ee25412d2c468f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2909279},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"toolbox\\\"},\\\"digest\\\":\\\"sha256:be436ccc2fa0597c1362e0f5910756439c4408dde5d08584102d5be628cbf8ce\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4489926},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"file-libs\\\"},\\\"digest\\\":\\\"sha256:1d5b3094e803ea3227a4606eb661a4b7132f7ca7dc66fc6660c0fc99ad85e423\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1135288},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"coreos-installer\\\"},\\\"digest\\\":\\\"sha256:a29523866624908d441151bcd3e76a228deb3acb648b3f9818dbda0354cccf5a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4713570},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"netavark\\\"},\\\"digest\\\":\\\"sha256:c95bf051edb5ff0f8c4fd0e85d595eb1b0dfe044f0cbfeffc052dacdf7f29200\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":5071668},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"coreutils-common\\\"},\\\"digest\\\":\\\"sha256:c2e3d48a36548ba37f96b377f8dbd5c26f850a87facdd5e9695deac78f4349ad\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":3583661},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"NetworkManager-team,WALinuxAgent-udev,afterburn-dracut,alternatives,audit-rules,authselect,azure-vm-utils,bsdtar,bubblewrap,bzip2,bzip2-libs,clevis,clevis-dracut,clevis-luks,clevis-systemd,cloud-utils-growpart,composefs,composefs-libs,console-login-helper-messages,console-login-helper-messages-issuegen,console-login-helper-messages-motdgen,console-login-helper-messages-profile,container-selinux,containers-common,criu-libs,crun-wasm,crypto-policies,cyrus-sasl-gssapi,dbus-common,device-mapper-event,device-mapper-event-libs,dracut-squash,efibootmgr,elfutils-default-yama-scope,fedora-gpg-keys,fedora-release-common,fedora-release-identity-coreos,file,filesystem,flatpak-session-helper,fstrm,fuse-sshfs,fuse3,gettext-envsubst,google-compute-engine-guest-configs-udev,hostname,ignition-grub,inih,ipcalc,iptables-legacy,iptables-legacy-libs,iptables-services,iptables-utils,irqbalance,jansson,jose,json-c,keyutils-libs,kpartx,libacl,libaio,libassuan,libattr,libbasicobjects,libcap-ng,libcbor,libcollection,libcom_err,libdaemon,libdhash,libeconf,libffi,libipa_hbac,libkcapi,libkcapi-hasher,libkcapi-hmaccalc,libluksmeta,libmaxminddb,libmd,libmnl,libndp,libnet,libnfnetlink,libnsl2,libpath_utils,libpciaccess,libpkgconf,libpsl,libref_array,libss,libsss_certmap,libsss_idmap,libsss_nss_idmap,libsss_sudo,libtalloc,libtasn1,libtdb,libteam,libtevent,libtool-ltdl,libuuid,libverto,libwbclient,lmdb-libs,logrotate,luksmeta,mokutil,nano-default-editor,npth,nss-altfiles,numactl-libs,os-prober,passim-libs,pciutils-libs,pcre2-syntax,pkgconf,pkgconf-m4,pkgconf-pkg-config,protobuf-c,publicsuffix-list-dafsa,rpcbind,rpm-ostree-libs,rpm-plugin-selinux,samba-common,selinux-policy,selinux-policy-targeted,setup,shadow-utils-subid,slirp4netns,snappy,sssd-krb5,sssd-ldap,sssd-nfs-idmap,systemd-sysusers,vim-data,which,xxhash-libs,yajl,zchunk-libs\\\"},\\\"digest\\\":\\\"sha256:9496a8be2eeb9a87d347acfcee58d9306f135540ee8e37eb3ad987581dff765d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2939519},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"glibc,glibc-gconv-extra,systemd\\\"},\\\"digest\\\":\\\"sha256:9d30927641e4b122e8d824ac266699d2d107f59a339f8b756e2271dc4dd913e1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":10825299},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"glibc-common,libldb,libsmbclient,samba-common-libs,systemd-pam,systemd-resolved\\\"},\\\"digest\\\":\\\"sha256:a7b2840639b289ea96a3852ca4eae05912a96f9505912b29a7af339b245872b0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":1617072},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"systemd-container,systemd-libs,systemd-shared,vim-minimal\\\"},\\\"digest\\\":\\\"sha256:65349c34bac1d174e0620778db6eb5149f1ea295bf8db891518844cda26ace9e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":5144732},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"NetworkManager-libnm,bootc,btrfs-progs,fwupd,grub2-tools,hwdata,intel-gpu-firmware,openssl-libs,qed-firmware\\\"},\\\"digest\\\":\\\"sha256:1d8d5df15c057fb0bb9cdfd4367787d77d371b6bda2cc04047b9c1c678e682ef\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":42751269},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"NetworkManager-cloud-setup,NetworkManager-tui,curl,dracut,dracut-network,iptables-nft,libcurl-minimal,libnvme,libsemanage,libsolv,pam-libs,rpm-libs,spdlog\\\"},\\\"digest\\\":\\\"sha256:b473f7dc83d93c16b40d9cc31a6bb5ef76707a33e86889ec601ee02567291537\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2795145},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"amd-ucode-firmware,audit,audit-libs,elfutils-libelf,elfutils-libs,kdump-utils,linux-firmware-whence,openssl,sssd-ad,sssd-client,sssd-common-pac,sssd-ipa,sssd-krb5-common\\\"},\\\"digest\\\":\\\"sha256:533180c47b158b0a6bf33e86bdf81651a140e7c9bc56911b6970eceff87e29ca\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2534549},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"crun,libdnf5-cli,libselinux,libselinux-utils,libusb1,libxcrypt,nfs-utils-coreos,openssh,ostree,ostree-libs,passt,passt-selinux,stalld\\\"},\\\"digest\\\":\\\"sha256:d23d5f5869880ea074fa6e6edaeae85e1576c3f6dc29b8e18f0a53c9cddf9b98\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2863966},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"bind-utils,libgcc,libnfsidmap\\\"},\\\"digest\\\":\\\"sha256:ec918952ea0be6f3d09c9460336e945784195c978c96180adb7ba627784a8d01\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":435911},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"NetworkManager,bootupd,coreos-installer-bootinfra,coreutils,criu,gnutls,iptables-libs,libdnf5,openssh-clients,openssh-server,pam,shadow-utils,sssd-common\\\"},\\\"digest\\\":\\\"sha256:efb7f3fdc7c2fcf55f4905a0991b53a34039bc7541ab1546be582d17b6d83c2b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":18190634},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"bind-libs,dnf5,grub2-common,grub2-pc-modules,grub2-tools-minimal,libstdc++\\\"},\\\"digest\\\":\\\"sha256:485614b3c43cfb88356d41749c360d8b93a7f094d903995f565caf982fb3ac76\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":8314064},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"afterburn,bash,gnupg2,zincati\\\"},\\\"digest\\\":\\\"sha256:0db4049d3050333774db21edb0db45ac21b19fba5824792a1ed15f1d286a06c6\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":13134890},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"ca-certificates,cryptsetup-libs,cyrus-sasl-lib,gettext-libs,iproute,kbd-misc,krb5-libs,libxml2,lvm2,lvm2-libs,nano,tpm2-tss,xfsprogs\\\"},\\\"digest\\\":\\\"sha256:90821b3f1f03fbfbde218773106eadff5b69ab53cef4be0c08e534342362159c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":14399528},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"acl,attr,cracklib,duktape,fuse3-libs,gdisk,gnulib-l10n,grep,gzip,keyutils,libevent,libfido2,libksba,libpcap,libpwquality,libseccomp,libslirp,lz4-libs,lzo,sdbus-cpp,sed,slang,squashfs-tools,tini-static,tpm2-tools,userspace-rcu\\\"},\\\"digest\\\":\\\"sha256:fdaf7b13c72c087a2743e5467201a338ca86d5a39b9a152d6a5eddfc6a2142fa\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4731079},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"bash-completion,catatonit,cpio,dbus-broker,efivar-libs,fuse-overlayfs,gawk,gdbm,gmp,libbpf,libbsd,libgpg-error,libidn2,libini_config,libyaml,libzstd,mpfr,net-tools,nettle,pcre2,pigz,popt,psmisc,readline,ssh-key-dir,wireguard-tools\\\"},\\\"digest\\\":\\\"sha256:d69070ac57be839ac5c06472bd203ff7b182c07c1306be81bc540a3c1b4b4b90\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":6229582},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"adcli,authselect-libs,device-mapper-multipath-libs,dosfstools,gdbm-libs,iproute-tc,iscsi-initiator-utils,json-glib,kmod,kmod-libs,libedit,libjose,libnetfilter_conntrack,libnl3,libnl3-cli,lsof,ncurses-libs,newt,nftables,oniguruma,procps-ng,sg3_utils-libs,teamd,tpm2-tss-fapi,util-linux-core,zram-generator\\\"},\\\"digest\\\":\\\"sha256:8695bb3a70e84ce1c88ee45302990fad406d86814583484ef3cbdccd8cecf601\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":6334442},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"avahi-libs,conmon,dbus-libs,device-mapper-libs,device-mapper-multipath,dnsmasq,gettext-runtime,iscsi-initiator-utils-iscsiuio,isns-utils-libs,jq,kbd,kbd-legacy,libblkid,libfdisk,libibverbs,libmodulemd,libmount,libnftnl,libnghttp2,libsmartcols,libtextstyle,mdadm,ncurses,ncurses-base,socat,tzdata\\\"},\\\"digest\\\":\\\"sha256:d5828248db0fce63bd1c94b95e7edfde8eb95e6e8d5389bd99f88c2279108e3a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":5802706},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"c-ares,chrony,cifs-utils,cryptsetup,device-mapper,e2fsprogs-libs,kexec-tools,less,libarchive,libcap,libdrm,libjcat,librepo,libsepol,libuv,libxmlb,lua-libs,makedumpfile,openldap,p11-kit-trust,pciutils,polkit,polkit-libs,rsync,xz,xz-libs\\\"},\\\"digest\\\":\\\"sha256:42cc657f8b091c99e1d1ad6fabf3bf2b9bcc7db018026cf79e2c933c45ec2cf8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":4692689},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"clevis-pin-tpm2,device-mapper-persistent-data,ethtool,expat,findutils,fmt,gpgme,iputils,libgcrypt,libtirpc,libunistring,lld18-libs,policycoreutils,rpm-sequoia,sg3_utils,shared-mime-info,sqlite-libs,tar,util-linux,zlib-ng-compat,zstd\\\"},\\\"digest\\\":\\\"sha256:534542b43ff7ea753de20654da0270696794422a2ca9286e61b3858459e936ad\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":15545631},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"aardvark-dns,diffutils,e2fsprogs,nvme-cli,p11-kit,sudo,wasmedge-rt\\\"},\\\"digest\\\":\\\"sha256:abfa54e525a7cc1c90c5709617ea663175fe9d7ddf26175015a2fd7cfa613de1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":7526442},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"initramfs,rpmostree-unpackaged-content\\\"},\\\"digest\\\":\\\"sha256:1737413f719d6914140f976f0226753b0afe2ce44903038c1bfe74bd7b498736\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":136471847},{\\\"annotations\\\":{\\\"ostree.components\\\":\\\"\\\"},\\\"digest\\\":\\\"sha256:9dad063a624b62064bf25dbbc2e802e472d636056f661f2a0be73efd8a4da98b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar+gzip\\\",\\\"size\\\":2341}],\\\"schemaVersion\\\":2}\",\n        \"ostree.container.image-config\" : \"{\\\"architecture\\\":\\\"amd64\\\",\\\"config\\\":{\\\"Cmd\\\":[\\\"/sbin/init\\\"],\\\"Labels\\\":{\\\"com.coreos.osname\\\":\\\"fedora-coreos\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"coreos-assembler.image-config-checksum\\\":\\\"6495271cd8f2df9041e93dd10ee0c267133d9be20ec69df12c86e554d65d5929\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"org.opencontainers.image.revision\\\":\\\"70e8ceed13d40be5df3cbf717c69a0212b1c4d22\\\",\\\"org.opencontainers.image.source\\\":\\\"https://github.com/coreos/fedora-coreos-config\\\",\\\"org.opencontainers.image.version\\\":\\\"42.20251012.3.0\\\",\\\"ostree.bootable\\\":\\\"true\\\",\\\"ostree.commit\\\":\\\"5e8106b13057e32cb23afc13db1eac5f9d59498679b7aada5da039a86ce9c185\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"ostree.linux\\\":\\\"6.16.10-200.fc42.x86_64\\\",\\\"rpmostree.inputhash\\\":\\\"9df5ffcc6dd2e74776a94fe06d32817fca048b13f75458f455ae5bea7311fa37\\\"}},\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"history\\\":[{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"ostree export of commit 5e8106b13057e32cb23afc13db1eac5f9d59498679b7aada5da039a86ce9c185\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"llvm18-libs-18.1.8-6.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"nvidia-gpu-firmware-20250917-2.fc42.noarch\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"containerd-2.0.6-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"kernel-modules-6.16.10-200.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"moby-engine-28.4.0-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"kernel-modules-core-6.16.10-200.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"podman-5:5.6.2-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"ignition-2.23.0-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"linux-firmware-20250917-2.fc42.noarch\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"libicu-76.1-4.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"rpm-4.20.1-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"kernel-core-6.16.10-200.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"docker-cli-28.4.0-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"amd-gpu-firmware-20250917-2.fc42.noarch\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"skopeo-1:1.20.0-3.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"git-core-2.51.0-2.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"samba-client-libs-2:4.22.4-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"rpm-ostree-2025.11-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"glib2-2.84.4-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"microcode_ctl-2:2.1-70.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"nmstate-2.2.52-2.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"runc-2:1.3.1-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"systemd-udev-257.9-2.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"toolbox-0.3-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"file-libs-5.46-3.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"coreos-installer-0.25.0-2.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"netavark-2:1.16.1-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"coreutils-common-9.6-6.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"139 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"glibc-gconv-extra-2.41-11.fc42.x86_64 and glibc-2.41-11.fc42.x86_64 and systemd-257.9-2.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"6 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"vim-minimal-2:9.1.1818-1.fc42.x86_64 and systemd-shared-257.9-2.fc42.x86_64 and systemd-libs-257.9-2.fc42.x86_64 and systemd-container-257.9-2.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"9 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"13 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"13 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"13 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"libgcc-15.2.1-1.fc42.x86_64 and libnfsidmap-1:2.8.4-0.fc42.x86_64 and bind-utils-32:9.18.39-3.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"13 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"6 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"bash-5.2.37-1.fc42.x86_64 and gnupg2-2.4.7-2.fc42.x86_64 and zincati-0.0.30-3.fc42.x86_64 and afterburn-5.10.0-1.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"13 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"26 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"26 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"26 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"26 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"26 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"21 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"7 components\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"initramfs (kernel 6.16.10-200.fc42.x86_64) and rpmostree-unpackaged-content\\\"},{\\\"created\\\":\\\"2025-10-25T02:24:05Z\\\",\\\"created_by\\\":\\\"Reserved for new packages\\\"}],\\\"os\\\":\\\"linux\\\",\\\"rootfs\\\":{\\\"diff_ids\\\":[\\\"sha256:b1c25315539e19904bd57264be9b8ef7acd9eb2d21996c989b29c1c622046b15\\\",\\\"sha256:921719229f2077d77dc4aa47938b2c670dcd91cd925702df6eb79adfd9798301\\\",\\\"sha256:d34f56390ac5bbe0336abb77131e64ac8b570e7c7bc6913b1be1896da2cb3856\\\",\\\"sha256:27d4a1ff2120046b55b9b033ab547b3fc0e51f59f89217f4b597007913776371\\\",\\\"sha256:ee85ed0500050fd5dce82597f3a37d647c7948734eb8dad132a35fce150261c4\\\",\\\"sha256:eb799cdf2aa5606fc63e742f6c963d911f1af27ffe2c5c5c70edbf857f0e4650\\\",\\\"sha256:714827bf67b18f37afbe351a3bdb016c3b1da5ff9901172a0488f745e1f71215\\\",\\\"sha256:80db08369ab883b6f486372fadc9fd141f3363af2f1758728aafe3d71040d640\\\",\\\"sha256:53270ec778bd4226af39fbda5dcbfb363b6660659fc9bd6c864ca322ba3537cf\\\",\\\"sha256:59bfbcfbaf85500666aaf25bf3d4e9b1dd745ddc587afbb525dab6d06f4ad7bf\\\",\\\"sha256:ea01a1e9341f24c303789108745770de4d49af081bfd2e77788e84b7b6ba8d9d\\\",\\\"sha256:5a8672a694602effce6f9c798975de4711180f30faf60c667c8406b7d3ed05a9\\\",\\\"sha256:c448fb220ca11a2e85a99edf6276f423fc543fe89ebf5180e195405706dd41dd\\\",\\\"sha256:fbc0e97de51af44e004c8d9f6d23ae192264eff999573a2746a0516c3efa6dba\\\",\\\"sha256:d51df56c7a6084590258c08f7159630342f12571d533faac5d983d50ab7d8f29\\\",\\\"sha256:84fe4589fdb53ad2f5250a6c3afc7cd58da7f8d30e01b5502ee19d3d7d85335f\\\",\\\"sha256:bc0e354702c177602d8e7e6c71b58670e0346348fa3e05149345963b8041cd07\\\",\\\"sha256:db0d04f2a9db0245e4a5cb4be5add769c4e072c361aed0a69fda4372b99bb150\\\",\\\"sha256:d78f7d37ebdcdc72023fc20118d00ca7d68ced70d747e08fd92d6447a0398ed8\\\",\\\"sha256:082c2d09e04b901c6f1d92ced0744a11b6e2184f0e35ee116acb9c4588709b2b\\\",\\\"sha256:41be02c8dc03ac878a6014c7a743b69ed4cfd396b2ba02167bfd8d2f81a129a9\\\",\\\"sha256:63ad20a8e1ba9cf8f3b28bc7d41dda9ee926cac5ec1699f01421e5eb72adac4e\\\",\\\"sha256:8ce68abc832b65572da8d56bb2afab1d736a144c3ee731391d77c85a8fec21be\\\",\\\"sha256:3b43a4eb26df7141dd0d86c4c2f770a2f0f164db6389fa6a080bba14a8348518\\\",\\\"sha256:98a501755b1fe05d40501503441d8de4edd0b2f270039d924b15a6b4893d3191\\\",\\\"sha256:00ed40aab4b27322b6010065c7ec0d4635606b30e5c61b3b828344daa6da0129\\\",\\\"sha256:ccd00f690f51e9c6a3f1f1f995b3700d23e5b15130e56f6a355d5afb4d80cc64\\\",\\\"sha256:dc072f0b0981a110340158606c284b4dfb26a5c4c5d8e0970ab5c48cb940c5af\\\",\\\"sha256:43410a1d044fd9c616f8878c849d8c797f737306662a4720fbd9cf8f0f57402d\\\",\\\"sha256:3659eb0dcede5043e15f1f071e1881b355fd198b5280378079b17d1f2492b696\\\",\\\"sha256:efd2cc629830e362d5589b70ebc9cfcbf56ce8ac251362baf5a794fb6b38ccc3\\\",\\\"sha256:ffc2d2575906e149012a852d4617d68f398bf13f7805dd88e8fa15b4818110eb\\\",\\\"sha256:90151d4cb4186ba3fc0d41bddda7337f4d91923f799889d18e9bf1b332bc2fd7\\\",\\\"sha256:854083883bc6a0f45e1d8d771d8b6bae5d8a0ce7e70963f143960debe294b6c9\\\",\\\"sha256:66e064b02f85e52f32d9a1c8996f16694f0ede3abc3ae2fc576078ce86a25d54\\\",\\\"sha256:a6cc39eb2e244070792f006bbe5b2b53d3d169f036a95cb6d26b84d833a96cb5\\\",\\\"sha256:a9d699e36f5dfa12116d0e4efd7e87f92e8ea7d50542cd3851c3a5f6710b7e58\\\",\\\"sha256:2eb95d436ed15dd775cb95d3cce844ed713ed9fd5aa8cdecf3658cc0da3234b3\\\",\\\"sha256:45edc446141cfb5412eca5a800f53d3cbd84172388c684f2f04905be654a6aa7\\\",\\\"sha256:8386085d0396a86c89f1f9590a6fe12ff00112f8a5f54a5e6beba72c10041cff\\\",\\\"sha256:5fca8832f783afe2b15cef7b9d83a633e9970d3d98627d465d60b16be4f7c522\\\",\\\"sha256:a504dc644eb9b1d848d85a66cb88bdce21e27c9bdee5b3f4789e1443bc4babfa\\\",\\\"sha256:a94abf018f956bc054752db132113aed80745ddf91c263d87d5902adbf1d6f5a\\\",\\\"sha256:8ad662bf7df0e2c5a8e0b4d38b76be4897009458b3e3cd34e0a8e35094f3157f\\\",\\\"sha256:433a908e2ce529eb20bb5e22b9af4f8adabf6cb56fcf960c759a3ddbcbf18afa\\\",\\\"sha256:c6b12d7d9cd26f8b095dd143ba688b5db6047f3d3e67c404a52d7ca33005b253\\\",\\\"sha256:c3ef8d2fe35a06b6791619afb202b47dd28f2dd03ad85dddd83a61cb47432b7c\\\",\\\"sha256:381afe59f9bb99a51f36bf52b361e7e0e766b2d21fc54c06275fef02a7394d5f\\\",\\\"sha256:ab1c543c8c91f60d062f0967cb24d5d29e0ca6a696599fe13a240ec6f0d612f1\\\",\\\"sha256:736cd8dcd150530223e4db62f66beaa38ae946b8a227b7661a004e7c2d796564\\\",\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"],\\\"type\\\":\\\"layers\\\"}}\",\n        \"ostree.importer.version\" : \"0.15.3\"\n      },\n      \"base-removals\" : [],\n      \"pinned\" : false,\n      \"osname\" : \"fedora-coreos\",\n      \"base-remote-replacements\" : {      },\n      \"regenerate-initramfs\" : false,\n      \"checksum\" : \"efc636201dd1c450aa96463c8d7bdbb314e52302bcd6f49ac263f1e47c40895e\",\n      \"container-image-reference-digest\" : \"sha256:1693b47dfccebdde19e81c3d0a0392010f0ec67e827f096d1b3f8aec662eb5cf\",\n      \"requested-base-local-replacements\" : [],\n      \"id\" : \"fedora-coreos-efc636201dd1c450aa96463c8d7bdbb314e52302bcd6f49ac263f1e47c40895e.0\",\n      \"version\" : \"42.20251012.3.0\",\n      \"requested-local-fileoverride-packages\" : [],\n      \"requested-base-removals\" : [],\n      \"requested-packages\" : [],\n      \"serial\" : 0,\n      \"timestamp\" : 1761359045,\n      \"staged\" : false,\n      \"booted\" : false,\n      \"container-image-reference\" : \"ostree-image-signed:docker://quay.io/fedora/fedora-coreos:stable\",\n      \"packages\" : [],\n      \"base-local-replacements\" : []\n    }\n  ],\n  \"transaction\" : null,\n  \"cached-update\" : null,\n  \"update-driver\" : {\n    \"driver-name\" : \"Zincati\",\n    \"driver-sd-unit\" : \"zincati.service\"\n  }\n}\n"
  },
  {
    "path": "tests/fixtures/rpm-ostree-status-annotation.json",
    "content": "{\n  \"deployments\" : [\n    {\n      \"unlocked\" : \"none\",\n      \"requested-local-packages\" : [],\n      \"base-commit-meta\" : {\n        \"ostree.manifest-digest\" : \"sha256:ca99893c80a7b84dd84d4143bd27538207c2f38ab6647a58d9c8caa251f9a087\",\n        \"ostree.manifest\" : \"{\\\"annotations\\\":{\\\"com.coreos.inputhash\\\":\\\"d4344b55ca10c0dd03c5356dcc25b4c9c63150b0443572630d60cc288b3f0b66\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"org.opencontainers.image.base.digest\\\":\\\"sha256:35e8f8fa9d5becaf51b24e43a6500b07c952535d541fbe4827d9ea25b47bdcdc\\\",\\\"org.opencontainers.image.created\\\":\\\"2025-11-11T05:05:57.966057969Z\\\",\\\"ostree.commit\\\":\\\"874d769fabfc867687ec4e87df0f591095f28f0593ec654724d67a3897ad7293\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"},\\\"config\\\":{\\\"digest\\\":\\\"sha256:36595514ca7f345233bb2cce5490564e637380065e2b0ab859378aa08d73ba8c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.config.v1+json\\\",\\\"size\\\":12891},\\\"layers\\\":[{\\\"digest\\\":\\\"sha256:1e21513139bd358597eb4d73df51f54919b14a85ab75a7fe4ce27eb176404b33\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":11743232},{\\\"digest\\\":\\\"sha256:84b0d4b004e65a82273fcb356ff5c28748617cf7cb5bd86af50f7b375b2a00d6\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":118822912},{\\\"digest\\\":\\\"sha256:26cd4cf26c27a03a5e129351e37e6787117b3845e351795d691edf20c606d8af\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":107453440},{\\\"digest\\\":\\\"sha256:3d50f0918738a738775794d0091c97dd4799b6f07bd53d7afed75132db30126c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":109865472},{\\\"digest\\\":\\\"sha256:1c60f7ec241407b6b940594b9a4a951f53cf441f488fc86883d5f4a07e366d09\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":92706304},{\\\"digest\\\":\\\"sha256:0f368702b102fe3bd4721f432d810b5c35d582495b1464a16e6a9f57ee753d1a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":91154944},{\\\"digest\\\":\\\"sha256:4ca6afabf53d566e1960bab5da5da4b0d209509ac9a8633a2779c99d3e801da3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":86071808},{\\\"digest\\\":\\\"sha256:03f9a0bbdb5255222566c3535f0c191312bda671df6eb3488363ebf7f4cafe0e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":49666560},{\\\"digest\\\":\\\"sha256:eb9911747f3ed7ad975bddd068c0837f41f3ea39fca56d1e7091408c737d2b91\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":46970368},{\\\"digest\\\":\\\"sha256:ca252b47e34216418dcf886e4247f5e6dd1f719dbff590405760ec29428a5fc3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":43703296},{\\\"digest\\\":\\\"sha256:236892ab73038e8a19f73d78254a8f0d98c53da6617abc35f37ae5690d79e5d3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":38363648},{\\\"digest\\\":\\\"sha256:4f5dd7ba7da1c4d097925197834509d83ddeb34aa56612cf51e2d6043b85fd44\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":31154688},{\\\"digest\\\":\\\"sha256:a0911dd09c0bf8301dda6d7882ff72d01d72e97119482cf5707bf89d58f21a39\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":30608896},{\\\"digest\\\":\\\"sha256:d58249f6b6627320ee4a21eea364b100a3ac28cea862dad4cf5a5305496cc308\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":30134272},{\\\"digest\\\":\\\"sha256:b13c214bb8688814ae22c28860839701149d3ae6be36b8e6f1aa02e345689f0a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":30286848},{\\\"digest\\\":\\\"sha256:c1635b1be93315f700bc6fa15679e502442d6a6540c0b9b574d5593e781adb17\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":26287616},{\\\"digest\\\":\\\"sha256:0943df7af78e8a99f10a70732cb00ddd452b7aa01b974456ddca328a514f325b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":25347584},{\\\"digest\\\":\\\"sha256:a2ab6f39bd4cbaeabbea36d8bcf837217cfdad39d5f6bf27205c2a9edf7d8271\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":21521920},{\\\"digest\\\":\\\"sha256:1222ed50be8cfe660399cc5daa7110a73b79caa236e182ed80d303e3433bc682\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":15416832},{\\\"digest\\\":\\\"sha256:c1269bdb4ced785eb3293c1412c036a08a093a22591274c242f04fd9d6bd7ec8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":15342080},{\\\"digest\\\":\\\"sha256:41be02c8dc03ac878a6014c7a743b69ed4cfd396b2ba02167bfd8d2f81a129a9\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14768640},{\\\"digest\\\":\\\"sha256:0c485b17dfabc2e2f134f2c603bc894108a6154f4d8f28353a69bc14861fe9c0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14067200},{\\\"digest\\\":\\\"sha256:cfdc188238560a5cb0f99b5c33e771a28dd69cc70154119e2eccd79892b448a1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":13361664},{\\\"digest\\\":\\\"sha256:ffb93695c76ce5253834fc4731e01fb1bf71b14083fcd8a07055d4a26eeefad7\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14410752},{\\\"digest\\\":\\\"sha256:9fe33a69a090e4ad1089cdb0cc455caa3de80bad2d84604405222d26dea53c56\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12705280},{\\\"digest\\\":\\\"sha256:46b3d16cc52358292859823438278c74c65ade3eb7ca8af5b1f1d5a19adfbcbe\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12533248},{\\\"digest\\\":\\\"sha256:9e4287bb224824cbfe345ccd505537f70afc33a41ba78de11972acd2535d00e0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12354560},{\\\"digest\\\":\\\"sha256:1497ef0372805976032de47832d9998ab5fa6d036f4613161990623924a04845\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12094464},{\\\"digest\\\":\\\"sha256:0fd3fb0b43fa84ffe30b42143f6635eda073980e0c1135bf1d604b90d374b476\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":13846016},{\\\"digest\\\":\\\"sha256:c1d3bc7a084f7ab71373ab88e7ef64bbb2bef1f198bb85a4136d064f1da71738\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":11383808},{\\\"digest\\\":\\\"sha256:356140b78de63e71091724dbd33194b823a27f3979238f7b6a46e06e8ca8b1a0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10920448},{\\\"digest\\\":\\\"sha256:35cda2f3cfa130523e02cff5399d9efbf5d918d631a1555f7839ede1388132c7\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10207232},{\\\"digest\\\":\\\"sha256:9e2de7f9b84aacfe75012afa3854d345b348c31df14a9bb62d244aa37206c846\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10170368},{\\\"digest\\\":\\\"sha256:b98763f47db1a15fb132765eb58d0108b7ae678fedb86aaf22662f9fd9e8d687\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9932800},{\\\"digest\\\":\\\"sha256:a12dcb8567dc883eef285a1980f332ff3ebea9944213c3e23f30bd0ed2195415\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9940992},{\\\"digest\\\":\\\"sha256:8ea0935531d48190187417115c52c173f2bcf0c290ca7d51459e4883b82b5d7d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9530368},{\\\"digest\\\":\\\"sha256:ea3930b9cebb80a14cebac09f87427666bb0fee756a6f6255a384556d1d0fb4f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9818624},{\\\"digest\\\":\\\"sha256:3a0864d162f16b837b6a7b451906869e97ef795dd9f75df8863853d295fcf65b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10203136},{\\\"digest\\\":\\\"sha256:35d18397046fe35f3bfbb5ed14dcdd258626851a2158d332eb77dcafa1b63c53\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":21944832},{\\\"digest\\\":\\\"sha256:4c2d4efb12b66af4025fa018d0f39f55a1e246d18cc79fc28d020d134477d729\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":3043328},{\\\"digest\\\":\\\"sha256:54a5f686cadd2d7da2322099f90e9c60eda894b959feb229bd8ad437942530ed\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":16370176},{\\\"digest\\\":\\\"sha256:80cf23024d5fbda9abd991db42e348c4bd4132ae9a0a05be0e9db6adf54dedb8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":66384384},{\\\"digest\\\":\\\"sha256:93382aac74ff6efa1ee85b58ba3187ea2284cdc7a32005483155c479bc423649\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":8549888},{\\\"digest\\\":\\\"sha256:c120c183c5bc296e79151fc487ae64b1db4379720d27e4dc0653400cd55f9a7a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":2904576},{\\\"digest\\\":\\\"sha256:d31f866ed5b31994bbe75cd8c9e27442c6e2ce44672c64c3afd224d2b8adffee\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4477952},{\\\"digest\\\":\\\"sha256:2fdc93a40e6a0092641e966e9e1685ba6d5d8210778dbdf17d8a25fa6a852e68\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":5941248},{\\\"digest\\\":\\\"sha256:0a06d997fde79db25ff52e7708bdb60deba0c0072874fce9cebbe504e87bf1c4\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":5634560},{\\\"digest\\\":\\\"sha256:1d8ae2a483924a057d187bf10e211a90b0c5900c452bbee31118baf5a87b153c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4525056},{\\\"digest\\\":\\\"sha256:16168ec7edeccb4c35afdbac3c49dd6a7acffd8fea017c6d59e0673b144762d8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4317184},{\\\"digest\\\":\\\"sha256:ebc547fdd009d0495ccb1b5c2771229a1eee56a82fe7b13e791a81ca18466d3a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4491776},{\\\"digest\\\":\\\"sha256:7e511f4d5a3148bf28d2396b9f6e125f7531b9223be17340bad760832276bd0f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":5358592},{\\\"digest\\\":\\\"sha256:b995627156e6da05641607eae6e7dc7ae06b89aa94b736dcee4468b41141b52a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4934144},{\\\"digest\\\":\\\"sha256:764738c7a32e83bfbfaa4ebbcd1183afa0cb8d84e324dc7f482326cd789795d3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":824320},{\\\"digest\\\":\\\"sha256:b2d48957e4153dc3d6af66ac713e0bda62f6ef0fb0bf9dd5b74da05e1e0ec874\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":24606720},{\\\"digest\\\":\\\"sha256:7fd424fb47d96e70e0ffda990e24b6cd590505f58ab92f69f5a2e4314deb1082\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":23746560},{\\\"digest\\\":\\\"sha256:4804f2c6cdd16c506ac80f7812a154bca6666a48f58448735b5b58f0c6a3d9a6\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":23077376},{\\\"digest\\\":\\\"sha256:f1b581528e14a4566f531caa7db1a83d626346fe5375d5b9852b96f184ce434d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":24469504},{\\\"digest\\\":\\\"sha256:1b7cfcd367baa5f5dc2062def14ad1f2c8ec42923cd73ffa0752a112aff3e47c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":3065856},{\\\"digest\\\":\\\"sha256:19c96992b5c0b542a6b53fe0bc8e750ad7d02d97d35c8efac2fcd9f622d28a48\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":40394240},{\\\"digest\\\":\\\"sha256:84a843e5eb5baea63131bfa22fc9962c309146d6a0989858bd3092902e392524\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10083328},{\\\"digest\\\":\\\"sha256:d5320712ecdd0cce292c3764e7ef55901d90b82eaad625b2a5fd2eb840187608\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14299648},{\\\"digest\\\":\\\"sha256:05bebbbdb8513ccf8c2333bef0a8499b3c49c45e68a0fd1e5ec51c5aa5faed13\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10052608},{\\\"digest\\\":\\\"sha256:260195cf2a424fc6b7feb3fba608b5535a2f6cc14dafa3c48ae621027fd11dba\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":27753472},{\\\"digest\\\":\\\"sha256:09907250730305bf0a07b248f6698f0c933129af926e20fe3b767298ee26cd08\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":204227072},{\\\"digest\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":139264},{\\\"digest\\\":\\\"sha256:f5a72b52d258849e351e108b3e6cd9ea762bd43f9824b545896b69cc997354b8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":3072}],\\\"mediaType\\\":\\\"application/vnd.oci.image.manifest.v1+json\\\",\\\"schemaVersion\\\":2}\",\n        \"ostree.container.image-config\" : \"{\\\"architecture\\\":\\\"amd64\\\",\\\"config\\\":{\\\"Cmd\\\":[\\\"/sbin/init\\\"],\\\"Env\\\":[\\\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\\\"],\\\"Labels\\\":{\\\"com.coreos.inputhash\\\":\\\"d4344b55ca10c0dd03c5356dcc25b4c9c63150b0443572630d60cc288b3f0b66\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"io.buildah.version\\\":\\\"1.41.5\\\",\\\"org.opencontainers.image.description\\\":\\\"Fedora CoreOS stable\\\",\\\"org.opencontainers.image.revision\\\":\\\"b931e35fa954903d18b7d41ab6ba2ed4b22cbe9d\\\",\\\"org.opencontainers.image.source\\\":\\\"https://github.com/coreos/fedora-coreos-config\\\",\\\"org.opencontainers.image.title\\\":\\\"Fedora CoreOS stable\\\",\\\"org.opencontainers.image.version\\\":\\\"43.20251024.3.0\\\",\\\"ostree.bootable\\\":\\\"1\\\",\\\"ostree.commit\\\":\\\"874d769fabfc867687ec4e87df0f591095f28f0593ec654724d67a3897ad7293\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"},\\\"StopSignal\\\":\\\"SIGRTMIN+3\\\"},\\\"created\\\":\\\"2025-11-11T05:05:57.966057969Z\\\",\\\"history\\\":[{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"ostree export of commit 874d769fabfc867687ec4e87df0f591095f28f0593ec654724d67a3897ad7293\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"llvm18-libs-18.1.8-6.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"nvidia-gpu-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"kernel-modules-6.17.1-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"containerd-2.1.4-4.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"moby-engine-28.5.1-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"kernel-modules-core-6.17.1-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"podman-5:5.6.2-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"linux-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"ignition-2.24.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"libicu-77.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"rpm-6.0.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"kernel-core-6.17.1-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"docker-cli-28.5.1-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"amd-gpu-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"skopeo-1:1.20.0-4.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"git-core-2.51.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"samba-client-libs-2:4.23.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"rpm-ostree-2025.11-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"glib2-2.86.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"microcode_ctl-2:2.1-71.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"nmstate-2.2.52-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"runc-2:1.3.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"systemd-udev-258-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"toolbox-0.3-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"file-libs-5.46-8.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"coreos-installer-0.25.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"netavark-2:1.16.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"systemd-258-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"coreutils-common-9.7-6.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"NetworkManager-libnm-1:1.54.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"hwdata-0.400-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"qed-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"bootc-1.8.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"fwupd-2.0.16-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"openssl-libs-1:3.5.1-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"intel-gpu-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"146 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"glibc-gconv-extra-2.42-4.fc43.x86_64 and glibc-2.42-4.fc43.x86_64 and systemd-shared-258-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"systemd-resolved-258-1.fc43.x86_64 and libdnf5-cli-5.2.17.0-2.fc43.x86_64 and libldb-2:4.23.1-1.fc43.x86_64 and samba-common-libs-2:4.23.1-1.fc43.x86_64 and libsmbclient-2:4.23.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"7 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"nfs-utils-coreos-1:2.8.4-0.fc43.x86_64 and libnfsidmap-1:2.8.4-0.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"libgcrypt-1.11.1-2.fc43.x86_64 and gawk-5.3.2-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"7 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"14 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"initramfs (kernel 6.17.1-300.fc43.x86_64) and rpmostree-unpackaged-content\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"Reserved for new packages\\\"},{\\\"comment\\\":\\\"FROM oci-archive\\\",\\\"created\\\":\\\"2025-11-11T05:05:50.180989273Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:50.241181866Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG NAME VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:50.290554774Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG DESCRIPTION NAME VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.740269035Z\\\",\\\"created_by\\\":\\\"|5 DESCRIPTION=Fedora CoreOS stable NAME=fedora-coreos VERSION=43.20251024.3.0 /bin/sh -c --mount=type=bind,from=builder,target=/var/tmp     --mount=type=bind,target=/run/src,rw       rm /run/src/out.ociarchive:sha256:d8f9ba59662180f39835f8fcff1723836a4767cbf39080a2d37d37af00441c95\\\"},{\\\"created\\\":\\\"2025-11-11T05:05:57.818105423Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) LABEL containers.bootc=1       ostree.bootable=1       org.opencontainers.image.version=$VERSION       com.coreos.osname=$NAME       org.opencontainers.image.title=$DESCRIPTION       org.opencontainers.image.description=$DESCRIPTION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.866664633Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) STOPSIGNAL SIGRTMIN+3\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.917905927Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) CMD [\\\\\\\"/sbin/init\\\\\\\"]\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.967853214Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) LABEL \\\\\\\"org.opencontainers.image.source\\\\\\\"=\\\\\\\"https://github.com/coreos/fedora-coreos-config\\\\\\\" \\\\\\\"org.opencontainers.image.revision\\\\\\\"=\\\\\\\"b931e35fa954903d18b7d41ab6ba2ed4b22cbe9d\\\\\\\" \\\\\\\"fedora-coreos.stream\\\\\\\"=\\\\\\\"stable\\\\\\\"fedora-coreos.stream=stable\\\",\\\"empty_layer\\\":true}],\\\"os\\\":\\\"linux\\\",\\\"rootfs\\\":{\\\"diff_ids\\\":[\\\"sha256:1e21513139bd358597eb4d73df51f54919b14a85ab75a7fe4ce27eb176404b33\\\",\\\"sha256:84b0d4b004e65a82273fcb356ff5c28748617cf7cb5bd86af50f7b375b2a00d6\\\",\\\"sha256:26cd4cf26c27a03a5e129351e37e6787117b3845e351795d691edf20c606d8af\\\",\\\"sha256:3d50f0918738a738775794d0091c97dd4799b6f07bd53d7afed75132db30126c\\\",\\\"sha256:1c60f7ec241407b6b940594b9a4a951f53cf441f488fc86883d5f4a07e366d09\\\",\\\"sha256:0f368702b102fe3bd4721f432d810b5c35d582495b1464a16e6a9f57ee753d1a\\\",\\\"sha256:4ca6afabf53d566e1960bab5da5da4b0d209509ac9a8633a2779c99d3e801da3\\\",\\\"sha256:03f9a0bbdb5255222566c3535f0c191312bda671df6eb3488363ebf7f4cafe0e\\\",\\\"sha256:eb9911747f3ed7ad975bddd068c0837f41f3ea39fca56d1e7091408c737d2b91\\\",\\\"sha256:ca252b47e34216418dcf886e4247f5e6dd1f719dbff590405760ec29428a5fc3\\\",\\\"sha256:236892ab73038e8a19f73d78254a8f0d98c53da6617abc35f37ae5690d79e5d3\\\",\\\"sha256:4f5dd7ba7da1c4d097925197834509d83ddeb34aa56612cf51e2d6043b85fd44\\\",\\\"sha256:a0911dd09c0bf8301dda6d7882ff72d01d72e97119482cf5707bf89d58f21a39\\\",\\\"sha256:d58249f6b6627320ee4a21eea364b100a3ac28cea862dad4cf5a5305496cc308\\\",\\\"sha256:b13c214bb8688814ae22c28860839701149d3ae6be36b8e6f1aa02e345689f0a\\\",\\\"sha256:c1635b1be93315f700bc6fa15679e502442d6a6540c0b9b574d5593e781adb17\\\",\\\"sha256:0943df7af78e8a99f10a70732cb00ddd452b7aa01b974456ddca328a514f325b\\\",\\\"sha256:a2ab6f39bd4cbaeabbea36d8bcf837217cfdad39d5f6bf27205c2a9edf7d8271\\\",\\\"sha256:1222ed50be8cfe660399cc5daa7110a73b79caa236e182ed80d303e3433bc682\\\",\\\"sha256:c1269bdb4ced785eb3293c1412c036a08a093a22591274c242f04fd9d6bd7ec8\\\",\\\"sha256:41be02c8dc03ac878a6014c7a743b69ed4cfd396b2ba02167bfd8d2f81a129a9\\\",\\\"sha256:0c485b17dfabc2e2f134f2c603bc894108a6154f4d8f28353a69bc14861fe9c0\\\",\\\"sha256:cfdc188238560a5cb0f99b5c33e771a28dd69cc70154119e2eccd79892b448a1\\\",\\\"sha256:ffb93695c76ce5253834fc4731e01fb1bf71b14083fcd8a07055d4a26eeefad7\\\",\\\"sha256:9fe33a69a090e4ad1089cdb0cc455caa3de80bad2d84604405222d26dea53c56\\\",\\\"sha256:46b3d16cc52358292859823438278c74c65ade3eb7ca8af5b1f1d5a19adfbcbe\\\",\\\"sha256:9e4287bb224824cbfe345ccd505537f70afc33a41ba78de11972acd2535d00e0\\\",\\\"sha256:1497ef0372805976032de47832d9998ab5fa6d036f4613161990623924a04845\\\",\\\"sha256:0fd3fb0b43fa84ffe30b42143f6635eda073980e0c1135bf1d604b90d374b476\\\",\\\"sha256:c1d3bc7a084f7ab71373ab88e7ef64bbb2bef1f198bb85a4136d064f1da71738\\\",\\\"sha256:356140b78de63e71091724dbd33194b823a27f3979238f7b6a46e06e8ca8b1a0\\\",\\\"sha256:35cda2f3cfa130523e02cff5399d9efbf5d918d631a1555f7839ede1388132c7\\\",\\\"sha256:9e2de7f9b84aacfe75012afa3854d345b348c31df14a9bb62d244aa37206c846\\\",\\\"sha256:b98763f47db1a15fb132765eb58d0108b7ae678fedb86aaf22662f9fd9e8d687\\\",\\\"sha256:a12dcb8567dc883eef285a1980f332ff3ebea9944213c3e23f30bd0ed2195415\\\",\\\"sha256:8ea0935531d48190187417115c52c173f2bcf0c290ca7d51459e4883b82b5d7d\\\",\\\"sha256:ea3930b9cebb80a14cebac09f87427666bb0fee756a6f6255a384556d1d0fb4f\\\",\\\"sha256:3a0864d162f16b837b6a7b451906869e97ef795dd9f75df8863853d295fcf65b\\\",\\\"sha256:35d18397046fe35f3bfbb5ed14dcdd258626851a2158d332eb77dcafa1b63c53\\\",\\\"sha256:4c2d4efb12b66af4025fa018d0f39f55a1e246d18cc79fc28d020d134477d729\\\",\\\"sha256:54a5f686cadd2d7da2322099f90e9c60eda894b959feb229bd8ad437942530ed\\\",\\\"sha256:80cf23024d5fbda9abd991db42e348c4bd4132ae9a0a05be0e9db6adf54dedb8\\\",\\\"sha256:93382aac74ff6efa1ee85b58ba3187ea2284cdc7a32005483155c479bc423649\\\",\\\"sha256:c120c183c5bc296e79151fc487ae64b1db4379720d27e4dc0653400cd55f9a7a\\\",\\\"sha256:d31f866ed5b31994bbe75cd8c9e27442c6e2ce44672c64c3afd224d2b8adffee\\\",\\\"sha256:2fdc93a40e6a0092641e966e9e1685ba6d5d8210778dbdf17d8a25fa6a852e68\\\",\\\"sha256:0a06d997fde79db25ff52e7708bdb60deba0c0072874fce9cebbe504e87bf1c4\\\",\\\"sha256:1d8ae2a483924a057d187bf10e211a90b0c5900c452bbee31118baf5a87b153c\\\",\\\"sha256:16168ec7edeccb4c35afdbac3c49dd6a7acffd8fea017c6d59e0673b144762d8\\\",\\\"sha256:ebc547fdd009d0495ccb1b5c2771229a1eee56a82fe7b13e791a81ca18466d3a\\\",\\\"sha256:7e511f4d5a3148bf28d2396b9f6e125f7531b9223be17340bad760832276bd0f\\\",\\\"sha256:b995627156e6da05641607eae6e7dc7ae06b89aa94b736dcee4468b41141b52a\\\",\\\"sha256:764738c7a32e83bfbfaa4ebbcd1183afa0cb8d84e324dc7f482326cd789795d3\\\",\\\"sha256:b2d48957e4153dc3d6af66ac713e0bda62f6ef0fb0bf9dd5b74da05e1e0ec874\\\",\\\"sha256:7fd424fb47d96e70e0ffda990e24b6cd590505f58ab92f69f5a2e4314deb1082\\\",\\\"sha256:4804f2c6cdd16c506ac80f7812a154bca6666a48f58448735b5b58f0c6a3d9a6\\\",\\\"sha256:f1b581528e14a4566f531caa7db1a83d626346fe5375d5b9852b96f184ce434d\\\",\\\"sha256:1b7cfcd367baa5f5dc2062def14ad1f2c8ec42923cd73ffa0752a112aff3e47c\\\",\\\"sha256:19c96992b5c0b542a6b53fe0bc8e750ad7d02d97d35c8efac2fcd9f622d28a48\\\",\\\"sha256:84a843e5eb5baea63131bfa22fc9962c309146d6a0989858bd3092902e392524\\\",\\\"sha256:d5320712ecdd0cce292c3764e7ef55901d90b82eaad625b2a5fd2eb840187608\\\",\\\"sha256:05bebbbdb8513ccf8c2333bef0a8499b3c49c45e68a0fd1e5ec51c5aa5faed13\\\",\\\"sha256:260195cf2a424fc6b7feb3fba608b5535a2f6cc14dafa3c48ae621027fd11dba\\\",\\\"sha256:09907250730305bf0a07b248f6698f0c933129af926e20fe3b767298ee26cd08\\\",\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"sha256:f5a72b52d258849e351e108b3e6cd9ea762bd43f9824b545896b69cc997354b8\\\"],\\\"type\\\":\\\"layers\\\"}}\",\n        \"ostree.importer.version\" : \"0.15.3\"\n      },\n      \"base-removals\" : [],\n      \"pinned\" : false,\n      \"osname\" : \"fedora-coreos\",\n      \"base-remote-replacements\" : {      },\n      \"regenerate-initramfs\" : false,\n      \"checksum\" : \"36ff46d732a070a1bf10f7157f764e316f99a836dcdbf56702798e5042411fe9\",\n      \"container-image-reference-digest\" : \"sha256:ca99893c80a7b84dd84d4143bd27538207c2f38ab6647a58d9c8caa251f9a087\",\n      \"requested-base-local-replacements\" : [],\n      \"id\" : \"fedora-coreos-36ff46d732a070a1bf10f7157f764e316f99a836dcdbf56702798e5042411fe9.0\",\n      \"version\" : \"43.20251024.3.0\",\n      \"requested-local-fileoverride-packages\" : [],\n      \"requested-base-removals\" : [],\n      \"requested-packages\" : [],\n      \"serial\" : 0,\n      \"timestamp\" : 1762837557,\n      \"staged\" : false,\n      \"booted\" : true,\n      \"container-image-reference\" : \"ostree-image-signed:docker://quay.io/fedora/fedora-coreos:stable\",\n      \"packages\" : [],\n      \"base-local-replacements\" : []\n    }\n  ],\n  \"transaction\" : null,\n  \"cached-update\" : null,\n  \"update-driver\" : {\n    \"driver-name\" : \"Zincati\",\n    \"driver-sd-unit\" : \"zincati.service\"\n  }\n}\n"
  },
  {
    "path": "tests/fixtures/rpm-ostree-status.json",
    "content": "{\n  \"deployments\" : [\n    {\n      \"unlocked\" : \"none\",\n      \"requested-local-packages\" : [],\n      \"base-commit-meta\" : {\n        \"ostree.manifest-digest\" : \"sha256:ca99893c80a7b84dd84d4143bd27538207c2f38ab6647a58d9c8caa251f9a087\",\n        \"ostree.manifest\" : \"{\\\"annotations\\\":{\\\"com.coreos.inputhash\\\":\\\"d4344b55ca10c0dd03c5356dcc25b4c9c63150b0443572630d60cc288b3f0b66\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"org.opencontainers.image.base.digest\\\":\\\"sha256:35e8f8fa9d5becaf51b24e43a6500b07c952535d541fbe4827d9ea25b47bdcdc\\\",\\\"org.opencontainers.image.created\\\":\\\"2025-11-11T05:05:57.966057969Z\\\",\\\"ostree.commit\\\":\\\"874d769fabfc867687ec4e87df0f591095f28f0593ec654724d67a3897ad7293\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"},\\\"config\\\":{\\\"digest\\\":\\\"sha256:36595514ca7f345233bb2cce5490564e637380065e2b0ab859378aa08d73ba8c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.config.v1+json\\\",\\\"size\\\":12891},\\\"layers\\\":[{\\\"digest\\\":\\\"sha256:1e21513139bd358597eb4d73df51f54919b14a85ab75a7fe4ce27eb176404b33\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":11743232},{\\\"digest\\\":\\\"sha256:84b0d4b004e65a82273fcb356ff5c28748617cf7cb5bd86af50f7b375b2a00d6\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":118822912},{\\\"digest\\\":\\\"sha256:26cd4cf26c27a03a5e129351e37e6787117b3845e351795d691edf20c606d8af\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":107453440},{\\\"digest\\\":\\\"sha256:3d50f0918738a738775794d0091c97dd4799b6f07bd53d7afed75132db30126c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":109865472},{\\\"digest\\\":\\\"sha256:1c60f7ec241407b6b940594b9a4a951f53cf441f488fc86883d5f4a07e366d09\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":92706304},{\\\"digest\\\":\\\"sha256:0f368702b102fe3bd4721f432d810b5c35d582495b1464a16e6a9f57ee753d1a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":91154944},{\\\"digest\\\":\\\"sha256:4ca6afabf53d566e1960bab5da5da4b0d209509ac9a8633a2779c99d3e801da3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":86071808},{\\\"digest\\\":\\\"sha256:03f9a0bbdb5255222566c3535f0c191312bda671df6eb3488363ebf7f4cafe0e\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":49666560},{\\\"digest\\\":\\\"sha256:eb9911747f3ed7ad975bddd068c0837f41f3ea39fca56d1e7091408c737d2b91\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":46970368},{\\\"digest\\\":\\\"sha256:ca252b47e34216418dcf886e4247f5e6dd1f719dbff590405760ec29428a5fc3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":43703296},{\\\"digest\\\":\\\"sha256:236892ab73038e8a19f73d78254a8f0d98c53da6617abc35f37ae5690d79e5d3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":38363648},{\\\"digest\\\":\\\"sha256:4f5dd7ba7da1c4d097925197834509d83ddeb34aa56612cf51e2d6043b85fd44\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":31154688},{\\\"digest\\\":\\\"sha256:a0911dd09c0bf8301dda6d7882ff72d01d72e97119482cf5707bf89d58f21a39\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":30608896},{\\\"digest\\\":\\\"sha256:d58249f6b6627320ee4a21eea364b100a3ac28cea862dad4cf5a5305496cc308\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":30134272},{\\\"digest\\\":\\\"sha256:b13c214bb8688814ae22c28860839701149d3ae6be36b8e6f1aa02e345689f0a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":30286848},{\\\"digest\\\":\\\"sha256:c1635b1be93315f700bc6fa15679e502442d6a6540c0b9b574d5593e781adb17\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":26287616},{\\\"digest\\\":\\\"sha256:0943df7af78e8a99f10a70732cb00ddd452b7aa01b974456ddca328a514f325b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":25347584},{\\\"digest\\\":\\\"sha256:a2ab6f39bd4cbaeabbea36d8bcf837217cfdad39d5f6bf27205c2a9edf7d8271\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":21521920},{\\\"digest\\\":\\\"sha256:1222ed50be8cfe660399cc5daa7110a73b79caa236e182ed80d303e3433bc682\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":15416832},{\\\"digest\\\":\\\"sha256:c1269bdb4ced785eb3293c1412c036a08a093a22591274c242f04fd9d6bd7ec8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":15342080},{\\\"digest\\\":\\\"sha256:41be02c8dc03ac878a6014c7a743b69ed4cfd396b2ba02167bfd8d2f81a129a9\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14768640},{\\\"digest\\\":\\\"sha256:0c485b17dfabc2e2f134f2c603bc894108a6154f4d8f28353a69bc14861fe9c0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14067200},{\\\"digest\\\":\\\"sha256:cfdc188238560a5cb0f99b5c33e771a28dd69cc70154119e2eccd79892b448a1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":13361664},{\\\"digest\\\":\\\"sha256:ffb93695c76ce5253834fc4731e01fb1bf71b14083fcd8a07055d4a26eeefad7\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14410752},{\\\"digest\\\":\\\"sha256:9fe33a69a090e4ad1089cdb0cc455caa3de80bad2d84604405222d26dea53c56\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12705280},{\\\"digest\\\":\\\"sha256:46b3d16cc52358292859823438278c74c65ade3eb7ca8af5b1f1d5a19adfbcbe\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12533248},{\\\"digest\\\":\\\"sha256:9e4287bb224824cbfe345ccd505537f70afc33a41ba78de11972acd2535d00e0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12354560},{\\\"digest\\\":\\\"sha256:1497ef0372805976032de47832d9998ab5fa6d036f4613161990623924a04845\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":12094464},{\\\"digest\\\":\\\"sha256:0fd3fb0b43fa84ffe30b42143f6635eda073980e0c1135bf1d604b90d374b476\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":13846016},{\\\"digest\\\":\\\"sha256:c1d3bc7a084f7ab71373ab88e7ef64bbb2bef1f198bb85a4136d064f1da71738\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":11383808},{\\\"digest\\\":\\\"sha256:356140b78de63e71091724dbd33194b823a27f3979238f7b6a46e06e8ca8b1a0\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10920448},{\\\"digest\\\":\\\"sha256:35cda2f3cfa130523e02cff5399d9efbf5d918d631a1555f7839ede1388132c7\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10207232},{\\\"digest\\\":\\\"sha256:9e2de7f9b84aacfe75012afa3854d345b348c31df14a9bb62d244aa37206c846\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10170368},{\\\"digest\\\":\\\"sha256:b98763f47db1a15fb132765eb58d0108b7ae678fedb86aaf22662f9fd9e8d687\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9932800},{\\\"digest\\\":\\\"sha256:a12dcb8567dc883eef285a1980f332ff3ebea9944213c3e23f30bd0ed2195415\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9940992},{\\\"digest\\\":\\\"sha256:8ea0935531d48190187417115c52c173f2bcf0c290ca7d51459e4883b82b5d7d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9530368},{\\\"digest\\\":\\\"sha256:ea3930b9cebb80a14cebac09f87427666bb0fee756a6f6255a384556d1d0fb4f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":9818624},{\\\"digest\\\":\\\"sha256:3a0864d162f16b837b6a7b451906869e97ef795dd9f75df8863853d295fcf65b\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10203136},{\\\"digest\\\":\\\"sha256:35d18397046fe35f3bfbb5ed14dcdd258626851a2158d332eb77dcafa1b63c53\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":21944832},{\\\"digest\\\":\\\"sha256:4c2d4efb12b66af4025fa018d0f39f55a1e246d18cc79fc28d020d134477d729\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":3043328},{\\\"digest\\\":\\\"sha256:54a5f686cadd2d7da2322099f90e9c60eda894b959feb229bd8ad437942530ed\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":16370176},{\\\"digest\\\":\\\"sha256:80cf23024d5fbda9abd991db42e348c4bd4132ae9a0a05be0e9db6adf54dedb8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":66384384},{\\\"digest\\\":\\\"sha256:93382aac74ff6efa1ee85b58ba3187ea2284cdc7a32005483155c479bc423649\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":8549888},{\\\"digest\\\":\\\"sha256:c120c183c5bc296e79151fc487ae64b1db4379720d27e4dc0653400cd55f9a7a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":2904576},{\\\"digest\\\":\\\"sha256:d31f866ed5b31994bbe75cd8c9e27442c6e2ce44672c64c3afd224d2b8adffee\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4477952},{\\\"digest\\\":\\\"sha256:2fdc93a40e6a0092641e966e9e1685ba6d5d8210778dbdf17d8a25fa6a852e68\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":5941248},{\\\"digest\\\":\\\"sha256:0a06d997fde79db25ff52e7708bdb60deba0c0072874fce9cebbe504e87bf1c4\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":5634560},{\\\"digest\\\":\\\"sha256:1d8ae2a483924a057d187bf10e211a90b0c5900c452bbee31118baf5a87b153c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4525056},{\\\"digest\\\":\\\"sha256:16168ec7edeccb4c35afdbac3c49dd6a7acffd8fea017c6d59e0673b144762d8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4317184},{\\\"digest\\\":\\\"sha256:ebc547fdd009d0495ccb1b5c2771229a1eee56a82fe7b13e791a81ca18466d3a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4491776},{\\\"digest\\\":\\\"sha256:7e511f4d5a3148bf28d2396b9f6e125f7531b9223be17340bad760832276bd0f\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":5358592},{\\\"digest\\\":\\\"sha256:b995627156e6da05641607eae6e7dc7ae06b89aa94b736dcee4468b41141b52a\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":4934144},{\\\"digest\\\":\\\"sha256:764738c7a32e83bfbfaa4ebbcd1183afa0cb8d84e324dc7f482326cd789795d3\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":824320},{\\\"digest\\\":\\\"sha256:b2d48957e4153dc3d6af66ac713e0bda62f6ef0fb0bf9dd5b74da05e1e0ec874\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":24606720},{\\\"digest\\\":\\\"sha256:7fd424fb47d96e70e0ffda990e24b6cd590505f58ab92f69f5a2e4314deb1082\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":23746560},{\\\"digest\\\":\\\"sha256:4804f2c6cdd16c506ac80f7812a154bca6666a48f58448735b5b58f0c6a3d9a6\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":23077376},{\\\"digest\\\":\\\"sha256:f1b581528e14a4566f531caa7db1a83d626346fe5375d5b9852b96f184ce434d\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":24469504},{\\\"digest\\\":\\\"sha256:1b7cfcd367baa5f5dc2062def14ad1f2c8ec42923cd73ffa0752a112aff3e47c\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":3065856},{\\\"digest\\\":\\\"sha256:19c96992b5c0b542a6b53fe0bc8e750ad7d02d97d35c8efac2fcd9f622d28a48\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":40394240},{\\\"digest\\\":\\\"sha256:84a843e5eb5baea63131bfa22fc9962c309146d6a0989858bd3092902e392524\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10083328},{\\\"digest\\\":\\\"sha256:d5320712ecdd0cce292c3764e7ef55901d90b82eaad625b2a5fd2eb840187608\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":14299648},{\\\"digest\\\":\\\"sha256:05bebbbdb8513ccf8c2333bef0a8499b3c49c45e68a0fd1e5ec51c5aa5faed13\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":10052608},{\\\"digest\\\":\\\"sha256:260195cf2a424fc6b7feb3fba608b5535a2f6cc14dafa3c48ae621027fd11dba\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":27753472},{\\\"digest\\\":\\\"sha256:09907250730305bf0a07b248f6698f0c933129af926e20fe3b767298ee26cd08\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":204227072},{\\\"digest\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":139264},{\\\"digest\\\":\\\"sha256:f5a72b52d258849e351e108b3e6cd9ea762bd43f9824b545896b69cc997354b8\\\",\\\"mediaType\\\":\\\"application/vnd.oci.image.layer.v1.tar\\\",\\\"size\\\":3072}],\\\"mediaType\\\":\\\"application/vnd.oci.image.manifest.v1+json\\\",\\\"schemaVersion\\\":2}\",\n        \"ostree.container.image-config\" : \"{\\\"architecture\\\":\\\"amd64\\\",\\\"config\\\":{\\\"Cmd\\\":[\\\"/sbin/init\\\"],\\\"Env\\\":[\\\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\\\"],\\\"Labels\\\":{\\\"com.coreos.inputhash\\\":\\\"d4344b55ca10c0dd03c5356dcc25b4c9c63150b0443572630d60cc288b3f0b66\\\",\\\"com.coreos.osname\\\":\\\"fedora-coreos\\\",\\\"containers.bootc\\\":\\\"1\\\",\\\"fedora-coreos.stream\\\":\\\"stable\\\",\\\"io.buildah.version\\\":\\\"1.41.5\\\",\\\"org.opencontainers.image.description\\\":\\\"Fedora CoreOS stable\\\",\\\"org.opencontainers.image.revision\\\":\\\"b931e35fa954903d18b7d41ab6ba2ed4b22cbe9d\\\",\\\"org.opencontainers.image.source\\\":\\\"https://github.com/coreos/fedora-coreos-config\\\",\\\"org.opencontainers.image.title\\\":\\\"Fedora CoreOS stable\\\",\\\"org.opencontainers.image.version\\\":\\\"43.20251024.3.0\\\",\\\"ostree.bootable\\\":\\\"1\\\",\\\"ostree.commit\\\":\\\"874d769fabfc867687ec4e87df0f591095f28f0593ec654724d67a3897ad7293\\\",\\\"ostree.final-diffid\\\":\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\"},\\\"StopSignal\\\":\\\"SIGRTMIN+3\\\"},\\\"created\\\":\\\"2025-11-11T05:05:57.966057969Z\\\",\\\"history\\\":[{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"ostree export of commit 874d769fabfc867687ec4e87df0f591095f28f0593ec654724d67a3897ad7293\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"llvm18-libs-18.1.8-6.fc42.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"nvidia-gpu-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"kernel-modules-6.17.1-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"containerd-2.1.4-4.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"moby-engine-28.5.1-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"kernel-modules-core-6.17.1-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"podman-5:5.6.2-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"linux-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"ignition-2.24.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"libicu-77.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"rpm-6.0.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"kernel-core-6.17.1-300.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"docker-cli-28.5.1-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"amd-gpu-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"skopeo-1:1.20.0-4.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"git-core-2.51.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"samba-client-libs-2:4.23.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"rpm-ostree-2025.11-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"glib2-2.86.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"microcode_ctl-2:2.1-71.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"nmstate-2.2.52-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"runc-2:1.3.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"systemd-udev-258-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"toolbox-0.3-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"file-libs-5.46-8.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"coreos-installer-0.25.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"netavark-2:1.16.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"systemd-258-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"coreutils-common-9.7-6.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"NetworkManager-libnm-1:1.54.0-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"hwdata-0.400-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"qed-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"bootc-1.8.0-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"fwupd-2.0.16-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"openssl-libs-1:3.5.1-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"intel-gpu-firmware-20251021-1.fc43.noarch\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"146 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"glibc-gconv-extra-2.42-4.fc43.x86_64 and glibc-2.42-4.fc43.x86_64 and systemd-shared-258-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"systemd-resolved-258-1.fc43.x86_64 and libdnf5-cli-5.2.17.0-2.fc43.x86_64 and libldb-2:4.23.1-1.fc43.x86_64 and samba-common-libs-2:4.23.1-1.fc43.x86_64 and libsmbclient-2:4.23.1-1.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"7 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"nfs-utils-coreos-1:2.8.4-0.fc43.x86_64 and libnfsidmap-1:2.8.4-0.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"10 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"libgcrypt-1.11.1-2.fc43.x86_64 and gawk-5.3.2-2.fc43.x86_64\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"7 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"20 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"14 components\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"initramfs (kernel 6.17.1-300.fc43.x86_64) and rpmostree-unpackaged-content\\\"},{\\\"created\\\":\\\"2025-11-11T05:04:00Z\\\",\\\"created_by\\\":\\\"Reserved for new packages\\\"},{\\\"comment\\\":\\\"FROM oci-archive\\\",\\\"created\\\":\\\"2025-11-11T05:05:50.180989273Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:50.241181866Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG NAME VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:50.290554774Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) ARG DESCRIPTION NAME VERSION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.740269035Z\\\",\\\"created_by\\\":\\\"|5 DESCRIPTION=Fedora CoreOS stable NAME=fedora-coreos VERSION=43.20251024.3.0 /bin/sh -c --mount=type=bind,from=builder,target=/var/tmp     --mount=type=bind,target=/run/src,rw       rm /run/src/out.ociarchive:sha256:d8f9ba59662180f39835f8fcff1723836a4767cbf39080a2d37d37af00441c95\\\"},{\\\"created\\\":\\\"2025-11-11T05:05:57.818105423Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) LABEL containers.bootc=1       ostree.bootable=1       org.opencontainers.image.version=$VERSION       com.coreos.osname=$NAME       org.opencontainers.image.title=$DESCRIPTION       org.opencontainers.image.description=$DESCRIPTION\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.866664633Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) STOPSIGNAL SIGRTMIN+3\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.917905927Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) CMD [\\\\\\\"/sbin/init\\\\\\\"]\\\",\\\"empty_layer\\\":true},{\\\"created\\\":\\\"2025-11-11T05:05:57.967853214Z\\\",\\\"created_by\\\":\\\"/bin/sh -c #(nop) LABEL \\\\\\\"org.opencontainers.image.source\\\\\\\"=\\\\\\\"https://github.com/coreos/fedora-coreos-config\\\\\\\" \\\\\\\"org.opencontainers.image.revision\\\\\\\"=\\\\\\\"b931e35fa954903d18b7d41ab6ba2ed4b22cbe9d\\\\\\\" \\\\\\\"fedora-coreos.stream\\\\\\\"=\\\\\\\"stable\\\\\\\"fedora-coreos.stream=stable\\\",\\\"empty_layer\\\":true}],\\\"os\\\":\\\"linux\\\",\\\"rootfs\\\":{\\\"diff_ids\\\":[\\\"sha256:1e21513139bd358597eb4d73df51f54919b14a85ab75a7fe4ce27eb176404b33\\\",\\\"sha256:84b0d4b004e65a82273fcb356ff5c28748617cf7cb5bd86af50f7b375b2a00d6\\\",\\\"sha256:26cd4cf26c27a03a5e129351e37e6787117b3845e351795d691edf20c606d8af\\\",\\\"sha256:3d50f0918738a738775794d0091c97dd4799b6f07bd53d7afed75132db30126c\\\",\\\"sha256:1c60f7ec241407b6b940594b9a4a951f53cf441f488fc86883d5f4a07e366d09\\\",\\\"sha256:0f368702b102fe3bd4721f432d810b5c35d582495b1464a16e6a9f57ee753d1a\\\",\\\"sha256:4ca6afabf53d566e1960bab5da5da4b0d209509ac9a8633a2779c99d3e801da3\\\",\\\"sha256:03f9a0bbdb5255222566c3535f0c191312bda671df6eb3488363ebf7f4cafe0e\\\",\\\"sha256:eb9911747f3ed7ad975bddd068c0837f41f3ea39fca56d1e7091408c737d2b91\\\",\\\"sha256:ca252b47e34216418dcf886e4247f5e6dd1f719dbff590405760ec29428a5fc3\\\",\\\"sha256:236892ab73038e8a19f73d78254a8f0d98c53da6617abc35f37ae5690d79e5d3\\\",\\\"sha256:4f5dd7ba7da1c4d097925197834509d83ddeb34aa56612cf51e2d6043b85fd44\\\",\\\"sha256:a0911dd09c0bf8301dda6d7882ff72d01d72e97119482cf5707bf89d58f21a39\\\",\\\"sha256:d58249f6b6627320ee4a21eea364b100a3ac28cea862dad4cf5a5305496cc308\\\",\\\"sha256:b13c214bb8688814ae22c28860839701149d3ae6be36b8e6f1aa02e345689f0a\\\",\\\"sha256:c1635b1be93315f700bc6fa15679e502442d6a6540c0b9b574d5593e781adb17\\\",\\\"sha256:0943df7af78e8a99f10a70732cb00ddd452b7aa01b974456ddca328a514f325b\\\",\\\"sha256:a2ab6f39bd4cbaeabbea36d8bcf837217cfdad39d5f6bf27205c2a9edf7d8271\\\",\\\"sha256:1222ed50be8cfe660399cc5daa7110a73b79caa236e182ed80d303e3433bc682\\\",\\\"sha256:c1269bdb4ced785eb3293c1412c036a08a093a22591274c242f04fd9d6bd7ec8\\\",\\\"sha256:41be02c8dc03ac878a6014c7a743b69ed4cfd396b2ba02167bfd8d2f81a129a9\\\",\\\"sha256:0c485b17dfabc2e2f134f2c603bc894108a6154f4d8f28353a69bc14861fe9c0\\\",\\\"sha256:cfdc188238560a5cb0f99b5c33e771a28dd69cc70154119e2eccd79892b448a1\\\",\\\"sha256:ffb93695c76ce5253834fc4731e01fb1bf71b14083fcd8a07055d4a26eeefad7\\\",\\\"sha256:9fe33a69a090e4ad1089cdb0cc455caa3de80bad2d84604405222d26dea53c56\\\",\\\"sha256:46b3d16cc52358292859823438278c74c65ade3eb7ca8af5b1f1d5a19adfbcbe\\\",\\\"sha256:9e4287bb224824cbfe345ccd505537f70afc33a41ba78de11972acd2535d00e0\\\",\\\"sha256:1497ef0372805976032de47832d9998ab5fa6d036f4613161990623924a04845\\\",\\\"sha256:0fd3fb0b43fa84ffe30b42143f6635eda073980e0c1135bf1d604b90d374b476\\\",\\\"sha256:c1d3bc7a084f7ab71373ab88e7ef64bbb2bef1f198bb85a4136d064f1da71738\\\",\\\"sha256:356140b78de63e71091724dbd33194b823a27f3979238f7b6a46e06e8ca8b1a0\\\",\\\"sha256:35cda2f3cfa130523e02cff5399d9efbf5d918d631a1555f7839ede1388132c7\\\",\\\"sha256:9e2de7f9b84aacfe75012afa3854d345b348c31df14a9bb62d244aa37206c846\\\",\\\"sha256:b98763f47db1a15fb132765eb58d0108b7ae678fedb86aaf22662f9fd9e8d687\\\",\\\"sha256:a12dcb8567dc883eef285a1980f332ff3ebea9944213c3e23f30bd0ed2195415\\\",\\\"sha256:8ea0935531d48190187417115c52c173f2bcf0c290ca7d51459e4883b82b5d7d\\\",\\\"sha256:ea3930b9cebb80a14cebac09f87427666bb0fee756a6f6255a384556d1d0fb4f\\\",\\\"sha256:3a0864d162f16b837b6a7b451906869e97ef795dd9f75df8863853d295fcf65b\\\",\\\"sha256:35d18397046fe35f3bfbb5ed14dcdd258626851a2158d332eb77dcafa1b63c53\\\",\\\"sha256:4c2d4efb12b66af4025fa018d0f39f55a1e246d18cc79fc28d020d134477d729\\\",\\\"sha256:54a5f686cadd2d7da2322099f90e9c60eda894b959feb229bd8ad437942530ed\\\",\\\"sha256:80cf23024d5fbda9abd991db42e348c4bd4132ae9a0a05be0e9db6adf54dedb8\\\",\\\"sha256:93382aac74ff6efa1ee85b58ba3187ea2284cdc7a32005483155c479bc423649\\\",\\\"sha256:c120c183c5bc296e79151fc487ae64b1db4379720d27e4dc0653400cd55f9a7a\\\",\\\"sha256:d31f866ed5b31994bbe75cd8c9e27442c6e2ce44672c64c3afd224d2b8adffee\\\",\\\"sha256:2fdc93a40e6a0092641e966e9e1685ba6d5d8210778dbdf17d8a25fa6a852e68\\\",\\\"sha256:0a06d997fde79db25ff52e7708bdb60deba0c0072874fce9cebbe504e87bf1c4\\\",\\\"sha256:1d8ae2a483924a057d187bf10e211a90b0c5900c452bbee31118baf5a87b153c\\\",\\\"sha256:16168ec7edeccb4c35afdbac3c49dd6a7acffd8fea017c6d59e0673b144762d8\\\",\\\"sha256:ebc547fdd009d0495ccb1b5c2771229a1eee56a82fe7b13e791a81ca18466d3a\\\",\\\"sha256:7e511f4d5a3148bf28d2396b9f6e125f7531b9223be17340bad760832276bd0f\\\",\\\"sha256:b995627156e6da05641607eae6e7dc7ae06b89aa94b736dcee4468b41141b52a\\\",\\\"sha256:764738c7a32e83bfbfaa4ebbcd1183afa0cb8d84e324dc7f482326cd789795d3\\\",\\\"sha256:b2d48957e4153dc3d6af66ac713e0bda62f6ef0fb0bf9dd5b74da05e1e0ec874\\\",\\\"sha256:7fd424fb47d96e70e0ffda990e24b6cd590505f58ab92f69f5a2e4314deb1082\\\",\\\"sha256:4804f2c6cdd16c506ac80f7812a154bca6666a48f58448735b5b58f0c6a3d9a6\\\",\\\"sha256:f1b581528e14a4566f531caa7db1a83d626346fe5375d5b9852b96f184ce434d\\\",\\\"sha256:1b7cfcd367baa5f5dc2062def14ad1f2c8ec42923cd73ffa0752a112aff3e47c\\\",\\\"sha256:19c96992b5c0b542a6b53fe0bc8e750ad7d02d97d35c8efac2fcd9f622d28a48\\\",\\\"sha256:84a843e5eb5baea63131bfa22fc9962c309146d6a0989858bd3092902e392524\\\",\\\"sha256:d5320712ecdd0cce292c3764e7ef55901d90b82eaad625b2a5fd2eb840187608\\\",\\\"sha256:05bebbbdb8513ccf8c2333bef0a8499b3c49c45e68a0fd1e5ec51c5aa5faed13\\\",\\\"sha256:260195cf2a424fc6b7feb3fba608b5535a2f6cc14dafa3c48ae621027fd11dba\\\",\\\"sha256:09907250730305bf0a07b248f6698f0c933129af926e20fe3b767298ee26cd08\\\",\\\"sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1\\\",\\\"sha256:f5a72b52d258849e351e108b3e6cd9ea762bd43f9824b545896b69cc997354b8\\\"],\\\"type\\\":\\\"layers\\\"}}\",\n        \"ostree.importer.version\" : \"0.15.3\"\n      },\n      \"base-removals\" : [],\n      \"pinned\" : false,\n      \"osname\" : \"fedora-coreos\",\n      \"base-remote-replacements\" : {      },\n      \"regenerate-initramfs\" : false,\n      \"checksum\" : \"36ff46d732a070a1bf10f7157f764e316f99a836dcdbf56702798e5042411fe9\",\n      \"container-image-reference-digest\" : \"sha256:ca99893c80a7b84dd84d4143bd27538207c2f38ab6647a58d9c8caa251f9a087\",\n      \"requested-base-local-replacements\" : [],\n      \"id\" : \"fedora-coreos-36ff46d732a070a1bf10f7157f764e316f99a836dcdbf56702798e5042411fe9.0\",\n      \"version\" : \"43.20251024.3.0\",\n      \"requested-local-fileoverride-packages\" : [],\n      \"requested-base-removals\" : [],\n      \"requested-packages\" : [],\n      \"serial\" : 0,\n      \"timestamp\" : 1762837557,\n      \"staged\" : false,\n      \"booted\" : true,\n      \"container-image-reference\" : \"ostree-image-signed:docker://quay.io/fedora/fedora-coreos:stable\",\n      \"packages\" : [],\n      \"base-local-replacements\" : []\n    }\n  ],\n  \"transaction\" : null,\n  \"cached-update\" : null,\n  \"update-driver\" : {\n    \"driver-name\" : \"Zincati\",\n    \"driver-sd-unit\" : \"zincati.service\"\n  }\n}\n"
  },
  {
    "path": "tests/kola/common/libtest.sh",
    "content": "# Source library for shell script tests\n# Copyright (C) 2020 Red Hat, Inc.\n# SPDX-License-Identifier: Apache-2.0\n\nrunv() {\n    (set -x && \"$@\")\n}\n\nN_TESTS=0\nok() {\n    echo \"ok\" $@\n    N_TESTS=$((N_TESTS + 1))\n}\n\ntap_finish() {\n    echo \"Completing TAP test with:\"\n    echo \"1..${N_TESTS}\"\n}\n\nfatal() {\n    echo error: $@ 1>&2; exit 1\n}\n\n# Dump ls -al + file contents to stderr, then fatal()\n_fatal_print_file() {\n    file=\"$1\"\n    shift\n    ls -al \"$file\" >&2\n    sed -e 's/^/# /' < \"$file\" >&2\n    fatal \"$@\"\n}\n\nassert_file_has_content () {\n    fpath=$1\n    shift\n    for re in \"$@\"; do\n        if ! grep -q -e \"$re\" \"$fpath\"; then\n            _fatal_print_file \"$fpath\" \"File '$fpath' doesn't match regexp '$re'\"\n        fi\n    done\n}\n\nassert_file_has_content_literal () {\n    fpath=$1; shift\n    for s in \"$@\"; do\n        if ! grep -q -F -e \"$s\" \"$fpath\"; then\n            _fatal_print_file \"$fpath\" \"File '$fpath' doesn't match fixed string list '$s'\"\n        fi\n    done\n}\n\nassert_not_file_has_content () {\n    fpath=$1\n    shift\n    for re in \"$@\"; do\n        if grep -q -e \"$re\" \"$fpath\"; then\n            _fatal_print_file \"$fpath\" \"File '$fpath' matches regexp '$re'\"\n        fi\n    done\n}\n\nassert_not_file_has_content_literal () {\n    fpath=$1; shift\n    for s in \"$@\"; do\n        if grep -q -F -e \"$s\" \"$fpath\"; then\n            _fatal_print_file \"$fpath\" \"File '$fpath' matches fixed string list '$s'\"\n        fi\n    done\n}\n\nassert_has_file () {\n    test -f \"$1\" || fatal \"Couldn't find '$1'\"\n}\n"
  },
  {
    "path": "tests/kola/dbus/test-experimental.sh",
    "content": "#!/bin/bash     \n\n# Tests for the `org.coreos.zincati.Experimental` interface.\n\nset -xeuo pipefail\n\n. ${KOLA_EXT_DATA}/libtest.sh\n\ncd $(mktemp -d)\n\n# Ensure that methods in this interface can only be called by root.\nif sudo -u core busctl call org.coreos.zincati /org/coreos/zincati org.coreos.zincati.Experimental LastRefreshTime 2> err.txt; then\n  fatal \"Non-root user calling Experimental interface unexpectedly succeeded\"\nfi\nassert_file_has_content err.txt \"Access denied\"\nok \"only allow root to call Experimental interface\"\n\n# Check Moo method.\nbusctl call org.coreos.zincati /org/coreos/zincati org.coreos.zincati.Experimental Moo b yes > output.txt\nassert_file_has_content output.txt \"Moooo mooo moooo!\"\nbusctl call org.coreos.zincati /org/coreos/zincati org.coreos.zincati.Experimental Moo b no > output.txt\nassert_file_has_content output.txt \"moo.\"\nok \"Moo method\"\n\n# Check LastRefreshTime method.\n# First, get the last refresh time\nresponse=$(busctl call org.coreos.zincati /org/coreos/zincati org.coreos.zincati.Experimental LastRefreshTime)\nlast_refresh_time=$(echo \"${response}\" | sed 's/[^0-9]*//g')\n# Sanity check that the last refresh time is a reasonable time.\ntest \"${last_refresh_time}\" -gt 1616414400 # 1616414400 is Monday, March 22, 2021 12:00:00 PM UTC.\nok \"LastRefreshTime method\"\n\n# Check that CLI commands work.\n/usr/libexec/zincati ex moo --talkative > output.txt\nassert_file_has_content output.txt \"Moooo mooo moooo!\"\ntest $(/usr/libexec/zincati ex last-refresh-time) -gt 1616414400\nok \"last-refresh-time CLI command\"\n"
  },
  {
    "path": "tests/kola/misc/test-status.sh",
    "content": "#!/bin/bash     \n\n# Simple sanity checks for systemd unit status updates.\n\nset -xeuo pipefail\n\n. ${KOLA_EXT_DATA}/libtest.sh\n\ncd $(mktemp -d)\n\nsystemctl stop zincati.service\necho \"updates.enabled = false\" > /etc/zincati/config.d/99-test-status-updates-enabled.toml\nsystemctl start zincati.service\nsystemctl show -p StatusText zincati.service > zincati_disabled_status.txt\nassert_file_has_content zincati_disabled_status.txt \\\n'initialization complete, auto-updates logic disabled by configuration'\nok \"status show initialization\"\n\nsystemctl stop zincati.service\necho \"updates.enabled = true\" > /etc/zincati/config.d/99-test-status-updates-enabled.toml\nsystemctl start zincati.service\nsystemctl show -p StatusText zincati.service > zincati_polling_status.txt\nassert_file_has_content zincati_polling_status.txt 'periodically polling for updates'\nok \"status show polling\"\n\n# Wait for Zincati to check for updates.\nfor i in {1..30}\ndo\n    systemctl show -p StatusText zincati.service > zincati_last_check_status.txt\n    if grep -q 'last checked' zincati_last_check_status.txt; then\n        break\n    fi\n    sleep 1\ndone\nassert_file_has_content zincati_last_check_status.txt 'periodically polling for updates (last checked'\nok \"status show last check\"\n\nsystemctl stop zincati.service\nsystemctl show -p StatusText zincati.service > zincati_stopped_status.txt\nassert_not_file_has_content zincati_stopped_status.txt '.+'\nok \"status show daemon stopped\"\n"
  },
  {
    "path": "tests/kola/server/config.fcc",
    "content": "variant: fcos\nversion: 1.1.0\nsystemd:\n  units:\n    - name: kolet-httpd.path\n      enabled: true\n      contents: |\n        [Path]\n        PathExists=/var/home/core/kolet\n        [Install]\n        WantedBy=kola-runext.service\n    - name: kolet-httpd.service\n      contents: |\n        [Service]\n        ExecStart=/var/home/core/kolet httpd --path /var/www/ -v\n        [Install]\n        WantedBy=kola-runext.service\n    - name: zincati.service\n      dropins:\n        - name: verbose.conf\n          contents: |\n            [Service]\n            Environment=ZINCATI_VERBOSITY=\"-vvvv\"\nstorage:\n  files:\n    - path: /etc/zincati/config.d/99-cincinnati-url.toml\n      contents:\n        inline: cincinnati.base_url=\"http://localhost\"\n      mode: 420\n  directories:\n    - path: /var/www\n      mode: 0666\n      user:\n        name: core\n      group:\n        name: core\n"
  },
  {
    "path": "tests/kola/server/test-deadend-release.sh",
    "content": "#!/bin/bash     \n\n# Test to check for correct detection of a dead-end release.\n\nset -xeuo pipefail\n\ncd $(mktemp -d)\n\n# Prepare a graph with the current booted deployment as a dead-end.\nmkdir /var/www/v1\ncat <<'EOF' > graph_template\n{\n  \"nodes\": [\n    {\n      \"version\": \"\",\n      \"metadata\": {\n          \"org.fedoraproject.coreos.releases.age_index\": \"0\",\n          \"org.fedoraproject.coreos.updates.deadend_reason\": \"https://github.com/coreos/fedora-coreos-tracker/issues/215\",\n          \"org.fedoraproject.coreos.scheme\": \"checksum\",\n          \"org.fedoraproject.coreos.updates.deadend\": \"true\"\n      },\n      \"payload\": \"\"\n    }\n  ],\n  \"edges\": []\n}\nEOF\nversion=\"$(/usr/bin/rpm-ostree status --json | jq '.deployments[0].version' -r)\"\npayload=\"$(/usr/bin/rpm-ostree status --json | jq '.deployments[0].checksum' -r)\"\njq \\\n  --arg version \"$version\" \\\n  --arg payload \"$payload\" \\\n  '.nodes[0].version=$version | .nodes[0].payload=$payload' \\\n  graph_template >/var/www/v1/graph\n\n\n# Now let Zincati check for updates (and detect that the current release is a dead-end).\necho \"updates.enabled = true\" > /etc/zincati/config.d/99-test-status-updates-enabled.toml\nsystemctl restart zincati.service\n\n# Wait up to 60 seconds for Zincati to detect that the current release is a dead-end release.\nfor i in {1..60}\ndo\n    if test -f /run/motd.d/85-zincati-deadend.motd; then\n        exit 0\n    fi\n    sleep 1\ndone\n\necho \"Dead-end MOTD file not found after timeout.\"\nexit 1\n"
  },
  {
    "path": "tests/kola/server/test-stream.sh",
    "content": "#!/bin/bash     \n\n# Test to check for correct detection of incorrect stream metadata in new releases and denylist functionality.\n\nset -xeuo pipefail\n\n. ${KOLA_EXT_DATA}/libtest.sh\n\nwait_journal_has_content() {\n  regex=$1\n\n  for i in {1..24}\n  do\n    journalctl -u zincati.service > journal.txt\n    if grep -q \"$regex\" journal.txt; then\n      break\n    fi\n    sleep 5\n  done\n  journalctl -u zincati.service > journal.txt\n  assert_file_has_content journal.txt \"$regex\"\n}\n\ncd $(mktemp -d)\n\ncase \"${AUTOPKGTEST_REBOOT_MARK:-}\" in\n  \"\")\n    # Prepare a graph template with two nodes. The node with the lower age index will be\n    # populated with the current booted deployment, and the node with the higher age index\n    # will be populated with a new version to possibly update to.\n    mkdir /var/www/v1\n    cat <<'EOF' > graph_template\n{\n  \"nodes\": [\n    {\n      \"version\": \"\",\n      \"metadata\": {\n          \"org.fedoraproject.coreos.releases.age_index\": \"0\",\n          \"org.fedoraproject.coreos.scheme\": \"checksum\"\n      },\n      \"payload\": \"\"\n    },\n    {\n      \"version\": \"\",\n      \"metadata\": {\n          \"org.fedoraproject.coreos.releases.age_index\" : \"1\",\n          \"org.fedoraproject.coreos.scheme\": \"checksum\"\n      },\n      \"payload\": \"\"\n    }\n  ],\n  \"edges\": [\n    [\n      0,\n      1\n    ]\n  ]\n}\nEOF\n\n    cur_version=\"$(/usr/bin/rpm-ostree status --json | jq '.deployments[0].version' -r)\"\n    cur_payload=\"$(/usr/bin/rpm-ostree status --json | jq '.deployments[0].checksum' -r)\"\n\n    # Prepare an OSTree repo in archive mode at `/var/www` and pull the currently booted commit into it.\n    ostree --repo=/var/www init --mode=\"archive\"\n    ostree --repo=/var/www pull-local /ostree/repo \"$cur_payload\"\n    # Create a new branch `test-branch` by creating a dummy commit.\n    ostree --repo=/var/www commit --branch='test-branch' --tree ref=\"$cur_payload\" \\\n            --add-metadata-string version='dummy' --keep-metadata='fedora-coreos.stream' \\\n            --keep-metadata='coreos-assembler.basearch' --parent=\"$cur_payload\"\n    # Add the OSTree repo at /var/www as a new `local` remote.\n    ostree remote add --no-gpg-verify local http://localhost test-branch\n    # Rebase onto our local OSTree repo's `test-branch`.\n    rpm-ostree rebase local:test-branch\n    # Create a new commit on `test-branch` that has an unknown stream.\n    bad_version=\"$cur_version\".bad-update\n    bad_payload=\"$(ostree --repo=/var/www commit --branch=test-branch --tree ref=\"$cur_payload\" \\\n                    --add-metadata-string version=\"$bad_version\" --add-metadata-string fedora-coreos.stream='unknown-stream' \\\n                    --keep-metadata='coreos-assembler.basearch' --parent=\"$cur_payload\")\"\n\n    jq \\\n        --arg cur_version \"$cur_version\" \\\n        --arg cur_payload \"$cur_payload\" \\\n        --arg bad_version \"$bad_version\" \\\n        --arg bad_payload \"$bad_payload\" \\\n        '.nodes[0].version=$cur_version | .nodes[0].payload=$cur_payload | .nodes[1].version=$bad_version | .nodes[1].payload=$bad_payload' \\\n        graph_template > /var/www/v1/graph\n\n    # Now let Zincati check for updates.\n    echo \"updates.enabled = true\" > /etc/zincati/config.d/99-test-status-updates-enabled.toml\n    systemctl restart zincati.service\n\n    wait_journal_has_content \"abandoned and blocked deployment '${bad_version}'\"\n    ok \"abandon update on different stream\"\n\n    wait_journal_has_content \"Found 1 possible update target present in denylist; ignoring\"\n    ok \"abandoned updates with incorrect stream in denylist\"\n\n    systemctl stop zincati.service\n\n    # Create a new commit on `test-branch` that's on the correct stream.\n    good_version=\"$cur_version\".good-update\n    good_payload=\"$(ostree --repo=/var/www commit --branch=test-branch --tree ref=\"$cur_payload\" \\\n                    --add-metadata-string version=\"$good_version\" --keep-metadata='fedora-coreos.stream' \\\n                    --keep-metadata='coreos-assembler.basearch' --parent=\"$cur_payload\")\"\n\n    jq \\\n        --arg cur_version \"$cur_version\" \\\n        --arg cur_payload \"$cur_payload\" \\\n        --arg good_version \"$good_version\" \\\n        --arg good_payload \"$good_payload\" \\\n        '.nodes[0].version=$cur_version | .nodes[0].payload=$cur_payload | .nodes[1].version=$good_version | .nodes[1].payload=$good_payload' \\\n        graph_template > /var/www/v1/graph\n\n    # We need to rebase onto our local OSTree repo's `test-branch` again because Zincati will tell rpm-ostree to cleanup bad releases.\n    rpm-ostree rebase local:test-branch\n\n    /tmp/autopkgtest-reboot-prepare rebooted\n    systemctl start zincati.service\n    ;;\n\n  rebooted)\n    rpm-ostree status > status.txt\n    assert_file_has_content status.txt \"Version:.*good-update\"\n    ok \"succesfully stage update on same stream\"\n    ;;\n\n  *) echo \"unexpected mark: ${AUTOPKGTEST_REBOOT_MARK}\"; exit 1;;\nesac\n"
  }
]