diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index b0500ec..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,248 +0,0 @@ -name: Build & Publish Release - -env: - NIX_CONFIG: experimental-features = nix-command flakes - -on: - workflow_dispatch: - inputs: - release_type: - description: "Release type: 'dev' or 'stable'" - required: true - default: "dev" - type: choice - options: - - dev - - stable - description: - description: "Release description" - required: true - type: string - release_sequence: - description: "Monotonic sequence allocated for this update channel" - required: true - type: string - expires_at: - description: "Signed manifest expiry in RFC 3339 format" - required: true - type: string - -concurrency: - group: iota-release-${{ inputs.release_type }} - cancel-in-progress: false - -jobs: - build: - name: Build & Publish Release - runs-on: host - steps: - - name: Set up repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Login to Docker Hub - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }} - run: | - set -eu - DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" - DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" - printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin" - - - name: Build & Push Docker image - run: | - nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest" - - - name: Read release metadata - id: version - env: - RELEASE_TYPE: ${{ inputs.release_type }} - run: | - set -eu - - VERSION="$(nix eval --raw .#default.version)" - SHORT_SHA="$(git rev-parse --short=7 HEAD)" - - case "$RELEASE_TYPE" in - dev) - TAG="${VERSION}-dev-${SHORT_SHA}" - PRERELEASE="true" - ;; - stable) - TAG="$VERSION" - PRERELEASE="false" - ;; - *) - echo "release_type must be either 'dev' or 'stable'" - exit 1 - ;; - esac - - echo "version=$VERSION" >> "$FORGEJO_OUTPUT" - echo "tag=$TAG" >> "$FORGEJO_OUTPUT" - echo "title=$TAG" >> "$FORGEJO_OUTPUT" - echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT" - ARCH="$(uname -m)" - echo "arch=$ARCH" >> "$FORGEJO_OUTPUT" - echo "asset_name=iota-${TAG}-linux-${ARCH}.zip" >> "$FORGEJO_OUTPUT" - echo "update_manifest_name=iota-update-${TAG}-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT" - echo "channel_tag=iota-updates-${RELEASE_TYPE}-linux-${ARCH}" >> "$FORGEJO_OUTPUT" - echo "channel_manifest_name=iota-update-linux-${ARCH}.json" >> "$FORGEJO_OUTPUT" - - - name: Build and validate installer bundle - env: - TAG: ${{ steps.version.outputs.tag }} - ASSET_NAME: ${{ steps.version.outputs.asset_name }} - ARCH: ${{ steps.version.outputs.arch }} - UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }} - CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }} - CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }} - RELEASE_TYPE: ${{ inputs.release_type }} - RELEASE_SEQUENCE: ${{ inputs.release_sequence }} - EXPIRES_AT: ${{ inputs.expires_at }} - SERVER_URL: ${{ forgejo.server_url }} - REPO: ${{ forgejo.repository }} - TOKEN: ${{ forgejo.token }} - IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }} - run: | - set -eu - : "${IOTA_RELEASE_SIGNING_KEY:?IOTA_RELEASE_SIGNING_KEY secret is required}" - - nix build "git+file://$PWD?submodules=1#default" --print-build-logs - mkdir -p dist/bin - install -m755 result/bin/iota dist/bin/iota - install -m755 result/bin/iota-daemon dist/bin/iota-daemon - install -m755 result/bin/iota-updater dist/bin/iota-updater - - UPDATE_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${TAG}" - UPDATE_CHANNEL_BASE_URL="${SERVER_URL%/}/${REPO}/releases/download/${CHANNEL_TAG}" - export UPDATE_CHANNEL_BASE_URL - nix-shell -p curl jq --run ' - set -eu - CHANNEL_STATUS="$(curl -L -sS -w "%{http_code}" -o previous-channel-manifest.json \ - -H "Authorization: token $TOKEN" \ - "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME")" - case "$CHANNEL_STATUS" in - 200) - jq -e \ - --argjson proposed "$RELEASE_SEQUENCE" \ - --arg version "$TAG" \ - "(.release_sequence < \$proposed) or (.release_sequence == \$proposed and .product_version == \$version)" \ - previous-channel-manifest.json >/dev/null || { - echo "release sequence must increase, or identify the same release during a refresh" - exit 1 - } - ;; - 404) ;; - *) - echo "could not read current channel manifest: HTTP $CHANNEL_STATUS" - exit 1 - ;; - esac - ' - PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - export ARCH EXPIRES_AT PUBLISHED_AT RELEASE_SEQUENCE RELEASE_TYPE TAG UPDATE_BASE_URL UPDATE_MANIFEST_NAME - nix-shell -p coreutils jq --run 'bash scripts/build-update-manifest.sh dist/bin "$TAG" "$RELEASE_TYPE" "$RELEASE_SEQUENCE" "$PUBLISHED_AT" "$EXPIRES_AT" linux "$ARCH" "$UPDATE_BASE_URL" "dist/$UPDATE_MANIFEST_NAME"' - UPDATE_PUBLIC_KEY="$(result/bin/iota-release sign "dist/$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME.sig")" - export UPDATE_PUBLIC_KEY - nix-shell -p jq zip --run 'bash scripts/build-release-bundle.sh \ - dist/bin \ - "$TAG" \ - "dist/$UPDATE_MANIFEST_NAME" \ - "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME" \ - "$UPDATE_PUBLIC_KEY" \ - "$UPDATE_CHANNEL_BASE_URL/$CHANNEL_MANIFEST_NAME.sig" \ - "$RELEASE_TYPE" \ - primary \ - "dist/$ASSET_NAME"' - result/bin/iota-bundle "dist/$ASSET_NAME" - - - name: Create release and upload release assets - env: - TOKEN: ${{ forgejo.token }} - API: ${{ forgejo.api_url }} - REPO: ${{ forgejo.repository }} - SHA: ${{ forgejo.sha }} - TAG: ${{ steps.version.outputs.tag }} - TITLE: ${{ steps.version.outputs.title }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} - ASSET_NAME: ${{ steps.version.outputs.asset_name }} - UPDATE_MANIFEST_NAME: ${{ steps.version.outputs.update_manifest_name }} - CHANNEL_TAG: ${{ steps.version.outputs.channel_tag }} - CHANNEL_MANIFEST_NAME: ${{ steps.version.outputs.channel_manifest_name }} - RELEASE_TYPE: ${{ inputs.release_type }} - DESCRIPTION: ${{ inputs.description }} - run: | - nix-shell -p curl jq --run ' - set -eu - - HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json \ - -H "Authorization: token $TOKEN" \ - "$API/repos/$REPO/releases/tags/$TAG") - - if [ "$HTTP_STATUS" = "200" ]; then - echo "Release $TAG already exists." - RELEASE_ID="$(jq -r .id release_out.json)" - else - echo "Creating release for $TAG" - RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg tag "$TAG" \ - --arg name "$TITLE" \ - --arg body "$DESCRIPTION" \ - --arg target "$SHA" \ - --argjson prerelease "$PRERELEASE" \ - '"'"'{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }'"'"')")" - RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" - fi - - upload_asset() { - TARGET_RELEASE_ID="$1" - ASSET="$2" - PATHNAME="$3" - curl -fsS -X POST "$API/repos/$REPO/releases/$TARGET_RELEASE_ID/assets?name=$ASSET" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@$PATHNAME" - } - - upload_asset "$RELEASE_ID" iota dist/bin/iota - upload_asset "$RELEASE_ID" iota-daemon dist/bin/iota-daemon - upload_asset "$RELEASE_ID" iota-updater dist/bin/iota-updater - upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig" - upload_asset "$RELEASE_ID" "$ASSET_NAME" "dist/$ASSET_NAME" - upload_asset "$RELEASE_ID" "$UPDATE_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME" - - CHANNEL_STATUS=$(curl -s -w "%{http_code}" -o channel_out.json \ - -H "Authorization: token $TOKEN" \ - "$API/repos/$REPO/releases/tags/$CHANNEL_TAG") - if [ "$CHANNEL_STATUS" = "200" ]; then - CHANNEL_RELEASE_ID="$(jq -r .id channel_out.json)" - else - CHANNEL_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg tag "$CHANNEL_TAG" \ - --arg name "Iota $RELEASE_TYPE update channel" \ - --arg target "$SHA" \ - '"'"'{ tag_name: $tag, name: $name, body: "Managed by the release workflow.", target_commitish: $target, draft: false, prerelease: true }'"'"')")" - CHANNEL_RELEASE_ID="$(echo "$CHANNEL_JSON" | jq -r .id)" - fi - - CHANNEL_ASSETS="$(curl -fsS \ - -H "Authorization: token $TOKEN" \ - "$API/repos/$REPO/releases/$CHANNEL_RELEASE_ID/assets")" - for CHANNEL_ASSET in "$CHANNEL_MANIFEST_NAME" "$CHANNEL_MANIFEST_NAME.sig"; do - echo "$CHANNEL_ASSETS" | jq -r --arg name "$CHANNEL_ASSET" '"'"'.[] | select(.name == $name) | .id'"'"' | while read -r ASSET_ID; do - [ -n "$ASSET_ID" ] || continue - curl -fsS -X DELETE "$API/repos/$REPO/releases/assets/$ASSET_ID" \ - -H "Authorization: token $TOKEN" - done - done - upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME.sig" "dist/$UPDATE_MANIFEST_NAME.sig" - upload_asset "$CHANNEL_RELEASE_ID" "$CHANNEL_MANIFEST_NAME" "dist/$UPDATE_MANIFEST_NAME" - ' diff --git a/.forgejo/workflows/validate.yml b/.forgejo/workflows/validate.yml index bceea5c..e264b17 100644 --- a/.forgejo/workflows/validate.yml +++ b/.forgejo/workflows/validate.yml @@ -32,3 +32,12 @@ jobs: - name: Check release binaries run: nix develop -c cargo check -p iota -p iota-daemon -p iota-updater -p iota-installer --locked + + - name: Check updater and installer formatting + run: nix develop -c cargo fmt -p iota-updater -p iota-installer -p iota --check + + - name: Run updater and installer tests + run: nix develop -c cargo test -p iota-updater -p iota-installer --locked + + - name: Check release scripts + run: bash -n scripts/build-update-manifest.sh scripts/build-release-bundle.sh diff --git a/flake.lock b/flake.lock index fa9b946..ad91937 100644 --- a/flake.lock +++ b/flake.lock @@ -1,21 +1,54 @@ { "nodes": { - "flake-parts": { - "inputs": { - "nixpkgs-lib": "nixpkgs-lib" - }, + "client": { + "flake": false, "locked": { - "lastModified": 1782949081, - "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", - "type": "github" + "lastModified": 1791114583, + "narHash": "sha256-J4j6LI+erWFp+maryBRN08Ra90BKLjv0NOhVC7subEY=", + "ref": "dev", + "rev": "d08a00a94acda0d1622de5960fa74750bf7d64db", + "revCount": 646, + "type": "git", + "url": "ssh://git@methanium.net/tensamin/client" }, "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" + "ref": "dev", + "type": "git", + "url": "ssh://git@methanium.net/tensamin/client" + } + }, + "iota": { + "flake": false, + "locked": { + "lastModified": 1791114268, + "narHash": "sha256-iKNN+La/hAKXxJL6wYti2jlZ4uS2C5dRkQyuVnciPwU=", + "ref": "main", + "rev": "3d824fde58f1a9ff3c2df45311f7dc658e9700dd", + "revCount": 327, + "type": "git", + "url": "ssh://git@methanium.net/tensamin/iota" + }, + "original": { + "ref": "main", + "type": "git", + "url": "ssh://git@methanium.net/tensamin/iota" + } + }, + "mtp": { + "flake": false, + "locked": { + "lastModified": 1791113691, + "narHash": "sha256-r7LAe6KUuVCXLzyTNo1vqQeXxmfsXuzFV+qV6teZd+Y=", + "ref": "master", + "rev": "ad489265deacadd6de52ed2129d071803a0e7247", + "revCount": 215, + "type": "git", + "url": "ssh://git@methanium.net/methanium/mtp" + }, + "original": { + "ref": "master", + "type": "git", + "url": "ssh://git@methanium.net/methanium/mtp" } }, "mtp-type-maps": { @@ -23,68 +56,112 @@ "locked": { "lastModified": 1790572556, "narHash": "sha256-ugNZR2Be9N9UjG0aVN8Yrk4hYOVC2edjtC9xBhbW35w=", - "ref": "refs/heads/main", + "ref": "main", "rev": "4f18c7a0d9b04d38a77fbb011c4f0b21c25bf7bf", "revCount": 28, "type": "git", - "url": "https://git.methanium.net/tensamin/mtp-type-maps" + "url": "ssh://git@methanium.net/tensamin/mtp-type-maps" }, "original": { + "ref": "main", "type": "git", - "url": "https://git.methanium.net/tensamin/mtp-type-maps" + "url": "ssh://git@methanium.net/tensamin/mtp-type-maps" } }, "nixpkgs": { "locked": { - "lastModified": 1784497964, - "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", - "owner": "nixos", + "lastModified": 1790981744, + "narHash": "sha256-sm6DclXJudZfP/pcDBQQsRqyMxBcQsuw+7yQr4rBBLE=", + "owner": "NixOS", "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "rev": "55ba7f49ef2962b42cbd126522b7df5f95037679", "type": "github" }, "original": { - "owner": "nixos", - "ref": "nixos-unstable", + "owner": "NixOS", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, - "nixpkgs-lib": { + "omega": { + "flake": false, "locked": { - "lastModified": 1782614948, - "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", - "owner": "nix-community", - "repo": "nixpkgs.lib", - "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", - "type": "github" + "lastModified": 1791114269, + "narHash": "sha256-56Nh5XnH7SK1P0kdtai/ZKcuQr8YlgDo5ndBf67YnFo=", + "ref": "main", + "rev": "993fa5ead0cfe5f5f0ab1ecd12ffe0f343380489", + "revCount": 157, + "type": "git", + "url": "ssh://git@methanium.net/tensamin/omega" }, "original": { - "owner": "nix-community", - "repo": "nixpkgs.lib", - "type": "github" + "ref": "main", + "type": "git", + "url": "ssh://git@methanium.net/tensamin/omega" + } + }, + "omikron": { + "flake": false, + "locked": { + "lastModified": 1791114268, + "narHash": "sha256-x6jc6l3LC3jTG/5YOuch32spGXfUzhO20M3g/Qmq2FY=", + "ref": "main", + "rev": "39371ad398d13aa1792c1ff58d2fb72f7be15787", + "revCount": 211, + "type": "git", + "url": "ssh://git@methanium.net/tensamin/omikron" + }, + "original": { + "ref": "main", + "type": "git", + "url": "ssh://git@methanium.net/tensamin/omikron" + } + }, + "prod-pins": { + "inputs": { + "client": "client", + "iota": "iota", + "mtp": "mtp", + "mtp-type-maps": "mtp-type-maps", + "nixpkgs": "nixpkgs", + "omega": "omega", + "omikron": "omikron", + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1791134396, + "narHash": "sha256-2HDKbqt9xNt7shkkASMmP7UGyz69NO/23j4IOXxADEs=", + "ref": "main", + "rev": "123205c97d1c75977ada8a3a80bd8323e19773bb", + "revCount": 3, + "type": "git", + "url": "ssh://git@methanium.net/tensamin/prod-pins" + }, + "original": { + "ref": "main", + "type": "git", + "url": "ssh://git@methanium.net/tensamin/prod-pins" } }, "root": { "inputs": { - "flake-parts": "flake-parts", - "mtp-type-maps": "mtp-type-maps", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" + "prod-pins": "prod-pins" } }, "rust-overlay": { "inputs": { "nixpkgs": [ + "prod-pins", "nixpkgs" ] }, "locked": { - "lastModified": 1784526465, - "narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=", + "lastModified": 1791101754, + "narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b", + "rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 51f0549..cc79214 100644 --- a/flake.nix +++ b/flake.nix @@ -1,389 +1,34 @@ { - description = "Iota"; + description = "Iota development and verification"; - inputs = { - nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - flake-parts.url = "github:hercules-ci/flake-parts"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - mtp-type-maps = { - url = "git+https://git.methanium.net/tensamin/mtp-type-maps"; - flake = false; - }; - }; + inputs.prod-pins.url = "git+ssh://git@methanium.net/tensamin/prod-pins?ref=main"; - outputs = - inputs@{ - self, - nixpkgs, - flake-parts, - rust-overlay, - ... - }: - flake-parts.lib.mkFlake { inherit inputs; } { - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - - perSystem = - { - self', - pkgs, - system, - ... - }: - let - rustPkgs = import nixpkgs { - inherit system; - overlays = [ (import rust-overlay) ]; - }; - rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { - extensions = [ - "rust-src" - "rust-analyzer" - "clippy" - "rustfmt" - ]; - }; - commonBuildInputs = with pkgs; [ - openssl - sqlite - ]; - commonNativeBuildInputs = with pkgs; [ - cmake - perl - pkg-config - ]; - in - { - packages = { - default = pkgs.rustPlatform.buildRustPackage { - pname = "iota"; - version = "0.1.0"; - src = ./.; - cargoBuildFlags = [ - "-p" - "iota" - "-p" - "iota-daemon" - "-p" - "iota-updater" - "-p" - "iota-installer" - ]; - cargoLock = { - lockFile = ./Cargo.lock; - allowBuiltinFetchGit = true; - }; - nativeBuildInputs = commonNativeBuildInputs; - buildInputs = commonBuildInputs; - dontUseCmakeConfigure = true; - MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml"; - passthru.dataDir = "/var/lib/iota"; - }; - - iota-daemon = self'.packages.default.overrideAttrs (old: { - pname = "iota-daemon"; - cargoBuildFlags = [ - "-p" - "iota-daemon" - ]; - postInstall = '' - for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota-daemon" ]; then - rm "$f" - fi - done - ''; - }); - - iota-ui = self'.packages.default.overrideAttrs (old: { - pname = "iota-ui"; - cargoBuildFlags = [ - "-p" - "iota" - ]; - postInstall = '' - for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota" ]; then - rm "$f" - fi - done - if [ -f "$out/bin/iota" ]; then - mv "$out/bin/iota" "$out/bin/iota-ui" - fi - ''; - }); - }; - - devShells.default = pkgs.mkShell { - nativeBuildInputs = with pkgs; [ - rustToolchain - git - cmake - perl - pkg-config - ]; - buildInputs = commonBuildInputs; - }; - }; - - flake = { - nixosModules.default = - { - config, - pkgs, - lib, - ... - }: - let - cfg = config.services.iota; - defaultPackage = - self.packages.${pkgs.stdenv.hostPlatform.system}.default - or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); - - configFormat = pkgs.formats.yaml { }; - effectiveSettings = lib.recursiveUpdate { - port = cfg.port; - web = { - mode = "network"; - bind = cfg.bindAddress; - port = cfg.port; - required = true; - } - // lib.optionalAttrs (cfg.certFile != null) { - certificate = "${cfg.stateDir}/tls/cert.pem"; - key = "${cfg.stateDir}/tls/key.pem"; - }; - } cfg.settings; - sourceConfigFile = - if cfg.settingsFile != null then - cfg.settingsFile - else - configFormat.generate "iota-config.yaml" effectiveSettings; - configFile = "${cfg.stateDir}/config.yaml"; - - descriptionText = "Tensamin Iota daemon"; - in - { - options.services.iota = { - enable = lib.mkEnableOption "Enable the Iota service."; - - stateDir = lib.mkOption { - type = lib.types.str; - default = "/var/lib/iota"; - description = "Persistent mutable Iota state."; - }; - cacheDir = lib.mkOption { - type = lib.types.str; - default = "/var/cache/iota"; - }; - runtimeDir = lib.mkOption { - type = lib.types.str; - default = "/run/iota"; - }; - logDir = lib.mkOption { - type = lib.types.str; - default = "/var/log/iota"; - }; - assetDir = lib.mkOption { - type = lib.types.str; - default = "${cfg.package}/share/iota/web"; - }; - - certFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Path to the SSL certificate file (cert.pem)."; - }; - - keyFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Path to the SSL private key file (cert.key)."; - }; - - environmentFiles = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "Environment files to load for the Iota service."; - }; - - openFirewall = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to open the firewall for ports used by Iota."; - }; - - bindAddress = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "IP address to bind the HTTP server to."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 1984; - description = "TCP and UDP port for protocol-only Iota."; - }; - - omegaApiUrl = lib.mkOption { - type = lib.types.str; - default = "https://omega.tensamin.net"; - description = "Omega discovery API URL."; - }; - - package = lib.mkOption { - type = lib.types.package; - default = defaultPackage; - description = "The Iota package to use."; - }; - - settings = lib.mkOption { - type = lib.types.attrs; - default = { }; - description = "Configuration attributes for Iota, written to YAML."; - }; - - settingsFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to an existing YAML file to use instead of generating from settings."; - }; - }; - - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = (cfg.certFile == null) == (cfg.keyFile == null); - message = "services.iota: certFile and keyFile must be set together."; - } - ]; - - users.users.iota = { - isSystemUser = true; - group = "iota"; - home = cfg.stateDir; - createHome = true; - description = "Iota service user"; - shell = pkgs.bash; - }; - - users.groups.iota = { }; - - systemd.sockets.iota = { - description = "${descriptionText} IPC socket"; - wantedBy = [ "sockets.target" ]; - socketConfig = { - ListenStream = "/run/iota/iota.sock"; - SocketMode = "0660"; - SocketUser = "iota"; - SocketGroup = "iota"; - DirectoryMode = "0750"; - Backlog = 5; - RemoveOnStop = "true"; - }; - }; - - systemd.services.iota = { - description = descriptionText; - wantedBy = [ "multi-user.target" ]; - after = [ - "network.target" - "iota.socket" - ]; - requires = [ "iota.socket" ]; - - environment.OMEGA_API_URL = cfg.omegaApiUrl; - - serviceConfig = { - Type = "simple"; - User = "iota"; - Group = "iota"; - ExecStart = "${cfg.package}/bin/iota-daemon"; - - Restart = "on-failure"; - RestartSec = "5s"; - RuntimeDirectory = "iota"; - RuntimeDirectoryMode = "0750"; - RuntimeDirectoryPreserve = "yes"; - StateDirectory = "iota"; - StateDirectoryMode = "0750"; - CacheDirectory = "iota"; - CacheDirectoryMode = "0750"; - LogsDirectory = "iota"; - LogsDirectoryMode = "0750"; - - # Exit code 75 = restart requested - RestartPreventExitStatus = "0"; - RestartForceExitStatus = "75"; - - TimeoutStopSec = "10"; - KillMode = "mixed"; - KillSignal = "SIGTERM"; - - AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; - CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; - - ProtectSystem = "strict"; - ProtectHome = true; - PrivateTmp = true; - NoNewPrivileges = true; - ReadWritePaths = [ - cfg.stateDir - cfg.cacheDir - cfg.runtimeDir - cfg.logDir - ]; - ReadOnlyPaths = [ - cfg.assetDir - ]; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - LockPersonality = true; - MemoryDenyWriteExecute = true; - Environment = [ - "IOTA_SOCKET=/run/iota/iota.sock" - "IOTA_CONFIG_FILE=${configFile}" - "IOTA_STATE_DIR=${cfg.stateDir}" - "IOTA_CACHE_DIR=${cfg.cacheDir}" - "IOTA_RUNTIME_DIR=${cfg.runtimeDir}" - "IOTA_LOG_DIR=${cfg.logDir}" - "IOTA_ASSET_DIR=${cfg.assetDir}" - "IOTA_DEPLOYMENT_MODE=system_socket_activated" - "IOTA_SUPERVISOR=systemd" - ]; - ExecStartPre = "+${pkgs.writeShellScript "iota-setup" '' - # ponytail: Preserve daemon-assigned IDs; remove config.yaml to reseed changed declarative settings. - if [ ! -e ${configFile} ]; then - install -m 0640 -o iota -g iota ${sourceConfigFile} ${configFile} - fi - ${lib.optionalString (cfg.certFile != null) '' - install -d -m 0700 -o iota -g iota ${cfg.stateDir}/tls - install -m 0644 -o iota -g iota ${cfg.certFile} ${cfg.stateDir}/tls/cert.pem - install -m 0600 -o iota -g iota ${cfg.keyFile} ${cfg.stateDir}/tls/key.pem - ''} - ''}"; - } - // lib.optionalAttrs (cfg.environmentFiles != [ ]) { - EnvironmentFile = cfg.environmentFiles; - }; - }; - - networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ cfg.port ]; - allowedUDPPorts = [ cfg.port ]; - }; - }; - }; + outputs = { self, prod-pins, ... }: + let + forSystems = prod-pins.inputs.nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ]; + project = system: prod-pins.lib.mkPackages { + inherit system; + sources.iota = self; }; + in { + checks = forSystems (system: { inherit ((project system).packages) iota; }); + devShells = forSystems (system: { + default = (project system).devShells.iota.overrideAttrs (old: { + TENSAMIN_SOURCE = (project system).packages.iota.transformedSource; + shellHook = (old.shellHook or "") + '' + # Stage authoritative dependencies without changing the checkout. + export TENSAMIN_CHECKOUT="$PWD" + workRoot="/tmp/tensamin-dev-$UID/$(basename "$TENSAMIN_SOURCE")" + if [ ! -d "$workRoot" ]; then + mkdir -p "$(dirname "$workRoot")" + stage="$(mktemp -d "$workRoot.XXXXXX")" + cp -R "$TENSAMIN_SOURCE/." "$stage/" && + chmod -R u+w "$stage" && + mv -T "$stage" "$workRoot" || exit 1 + fi + cd "$workRoot" + ''; + }); + }); }; } diff --git a/iota-installer/bundle-files.txt b/iota-installer/bundle-files.txt index 8409753..a588bb9 100644 --- a/iota-installer/bundle-files.txt +++ b/iota-installer/bundle-files.txt @@ -5,6 +5,5 @@ systemd/iota-daemon.service systemd/iota-daemon.socket systemd/sysusers.d/iota.conf systemd/iota-update.service -systemd/iota-update.timer systemd/update.env manifest.json diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index 282953b..32bfaa8 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -31,6 +31,14 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) let product_version = product_version(staging.path())?; validate_update_environment(staging.path())?; + for directory in ["/usr/local/lib/systemd/system", "/etc/systemd/system"] { + let timer = Path::new(directory).join("iota-update.timer"); + if timer.exists() { + run("systemctl", &["disable", "--now", "iota-update.timer"])?; + fs::remove_file(timer).context("remove legacy unattended update timer")?; + } + } + render_daemon_service(&staging.path().join("systemd/iota-daemon.service"))?; let version_dir = format!( @@ -267,6 +275,9 @@ fn validate_update_environment(staging: &Path) -> Result<()> { if entries.len() != 5 { bail!("updater environment contains unexpected variables"); } + if !matches!(entries["IOTA_UPDATE_CHANNEL"], "stable" | "canary") { + bail!("update channel must be stable or canary"); + } let public_key = entries .get("IOTA_UPDATE_PUBLIC_KEY") .context("updater environment is missing IOTA_UPDATE_PUBLIC_KEY")?; @@ -395,10 +406,10 @@ mod tests { fn rejects_bundle_missing_contract_member() { let directory = tempfile::tempdir().unwrap(); let bundle = directory.path().join("release.zip"); - write_bundle(&bundle, Some("systemd/iota-update.timer"), "0.1.0"); + write_bundle(&bundle, Some("systemd/iota-update.service"), "0.1.0"); let error = validate_linux_bundle(&bundle).unwrap_err(); - assert!(error.to_string().contains("systemd/iota-update.timer")); + assert!(error.to_string().contains("systemd/iota-update.service")); } #[test] diff --git a/iota-updater/src/config.rs b/iota-updater/src/config.rs new file mode 100644 index 0000000..1af3993 --- /dev/null +++ b/iota-updater/src/config.rs @@ -0,0 +1,83 @@ +use anyhow::{Context, Result, bail}; +use std::{collections::BTreeMap, fs, io::Write, path::Path}; + +const CONFIG: &str = "/etc/iota/update.env"; +const RELEASES: &str = "https://git.methanium.net/tensamin/prod-pins/releases/download"; + +pub fn manifest_url(channel: &str, architecture: &str) -> Result { + if !matches!(channel, "stable" | "canary") { + bail!("update channel must be stable or canary"); + } + if !matches!(architecture, "x86_64" | "aarch64") { + bail!("unsupported update architecture: {architecture}"); + } + Ok(format!( + "{RELEASES}/{channel}/iota-update-linux-{architecture}.json" + )) +} + +pub(crate) fn load() -> Result> { + let contents = fs::read_to_string(CONFIG).context("read /etc/iota/update.env")?; + let mut entries = BTreeMap::new(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (name, value) = line.split_once('=').context("invalid update.env entry")?; + if value.is_empty() || value.chars().any(char::is_whitespace) { + bail!("invalid update.env value for {name}"); + } + if entries.insert(name.to_owned(), value.to_owned()).is_some() { + bail!("duplicate update.env entry {name}"); + } + } + for name in [ + "IOTA_UPDATE_CHANNEL", + "IOTA_UPDATE_PUBLIC_KEY", + "IOTA_UPDATE_SIGNING_KEY_ID", + ] { + if !entries.contains_key(name) { + bail!("update.env is missing {name}"); + } + } + let url = manifest_url(&entries["IOTA_UPDATE_CHANNEL"], std::env::consts::ARCH)?; + if entries.get("IOTA_UPDATE_MANIFEST") != Some(&url) + || entries.get("IOTA_UPDATE_SIGNATURE") != Some(&format!("{url}.sig")) + { + bail!("update.env URLs do not match the selected prod-pins channel"); + } + Ok(entries) +} + +pub fn selected_channel() -> Result { + Ok(load()?["IOTA_UPDATE_CHANNEL"].clone()) +} + +pub fn select_channel(channel: &str) -> Result<()> { + let url = manifest_url(channel, std::env::consts::ARCH)?; + let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| anyhow::anyhow!(error))?; + let transaction = crate::transaction::UpdateTransaction::from_paths(&paths)?; + let _lock = transaction.acquire()?; + let mut entries = load()?; + entries.insert("IOTA_UPDATE_CHANNEL".into(), channel.into()); + entries.insert("IOTA_UPDATE_MANIFEST".into(), url.clone()); + entries.insert("IOTA_UPDATE_SIGNATURE".into(), format!("{url}.sig")); + let path = Path::new(CONFIG); + let parent = path.parent().context("update.env has no parent")?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary + .as_file() + .set_permissions(fs::metadata(path)?.permissions())?; + for (name, value) in entries { + writeln!(temporary, "{name}={value}")?; + } + temporary.as_file().sync_all()?; + temporary + .persist(path) + .map_err(|error| error.error) + .context("save update channel")?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index d245a2b..87c4ca7 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod manifest; pub mod transaction; @@ -80,9 +81,10 @@ struct UpdatePolicy { impl UpdatePolicy { fn from_environment() -> Result { + let config = config::load()?; Ok(Self { - channel: required_environment(CHANNEL_ENV)?, - signing_key_id: required_environment(SIGNING_KEY_ID_ENV)?, + channel: config[CHANNEL_ENV].clone(), + signing_key_id: config[SIGNING_KEY_ID_ENV].clone(), activation: ActivationPolicy::from_environment()?, }) } @@ -105,6 +107,8 @@ struct PendingActivation { product_version: String, daemon_was_active: bool, selected: bool, + #[serde(default)] + existing_target: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -172,21 +176,21 @@ impl DaemonSupervisor for SystemdSupervisor { } pub async fn check_update() -> Result { + let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| anyhow::anyhow!(error))?; + let transaction = UpdateTransaction::from_paths(&paths)?; + let _lock = transaction.acquire()?; + let policy = UpdatePolicy::from_environment()?; let Some(release) = configured_release().await? else { return Ok(false); }; - let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) - .map_err(|error| anyhow::anyhow!(error))?; - let policy = UpdatePolicy::from_environment()?; - let transaction = UpdateTransaction::from_paths(&paths)?; - let _lock = transaction.acquire()?; let state = load_or_initialize_update_state(&paths)?; if state.pending_activation.is_some() { bail!("an interrupted update requires iota-updater apply recovery"); } let decision = evaluate_candidate( &release.manifest, - current_version(&paths.install_root)?.as_deref(), + current_channel_version(&paths.install_root, &policy.channel)?.as_deref(), state.channels.get(&policy.channel), &policy.channel, &policy.signing_key_id, @@ -214,6 +218,12 @@ async fn apply_update_with_observer( ) -> Result { let transaction = UpdateTransaction::from_paths(paths)?; let _lock = transaction.acquire()?; + let configured_policy = UpdatePolicy::from_environment()?; + if policy.channel != configured_policy.channel + || policy.signing_key_id != configured_policy.signing_key_id + { + bail!("update configuration changed; retry apply"); + } let mut state = load_or_initialize_update_state(paths)?; recover_interrupted_activation( paths, @@ -228,7 +238,7 @@ async fn apply_update_with_observer( }; let decision = evaluate_candidate( &release.manifest, - current_version(&paths.install_root)?.as_deref(), + current_channel_version(&paths.install_root, &policy.channel)?.as_deref(), state.channels.get(&policy.channel), &policy.channel, &policy.signing_key_id, @@ -281,7 +291,24 @@ async fn apply_update_with_observer( UpdatePhase::ActivationStarted, Some(&release.manifest.product_version), ); - let activation = match transaction.activate(&release.manifest.product_version) { + let activation = match if state + .pending_activation + .as_ref() + .is_some_and(|pending| pending.existing_target) + { + let previous_target = transaction.current_target()?; + transaction + .rollback(&release.manifest.product_version) + .map(|()| Activation { + previous_target, + new_target: transaction + .root + .join("versions") + .join(&release.manifest.product_version), + }) + } else { + transaction.activate(&release.manifest.product_version) + } { Ok(activation) => activation, Err(error) => { remove_unselected_candidate(&state, &transaction.root)?; @@ -395,7 +422,7 @@ fn remove_unselected_candidate(state: &UpdateState, install_root: &Path) -> Resu return Ok(()); }; validate_pending_candidate(pending, install_root)?; - if pending.new_target.exists() { + if !pending.existing_target && pending.new_target.exists() { fs::remove_dir_all(&pending.new_target).with_context(|| { format!( "remove unselected candidate release {}", @@ -430,8 +457,20 @@ fn begin_activation_state( .root .join("versions") .join(&manifest.product_version); - if new_target.exists() { - bail!("release version already exists: {}", new_target.display()); + let existing_target = new_target.exists(); + if existing_target { + let mut installed = read_installed_manifest(&new_target)?; + // Refreshing signed expiry does not change the installed release contents. + installed.published_at = manifest.published_at.clone(); + installed.expires_at = manifest.expires_at.clone(); + if manifest::canonical_bytes(&installed)? != manifest::canonical_bytes(manifest)? { + bail!("existing release version has different signed metadata"); + } + for artifact in + select_host_artifacts(manifest, std::env::consts::OS, std::env::consts::ARCH)? + { + manifest::verify_artifact(&new_target.join(&artifact.path), &artifact)?; + } } let mut updated = state.clone(); updated.pending_activation = Some(PendingActivation { @@ -442,6 +481,7 @@ fn begin_activation_state( product_version: manifest.product_version.clone(), daemon_was_active, selected: true, + existing_target, }); save_update_state(&paths.update_status_file(), &updated)?; *state = updated; @@ -603,7 +643,11 @@ fn evaluate_candidate( ); } if manifest.release_sequence == state.highest_accepted_sequence { - return Ok(CandidateDecision::NoUpdate); + return Ok(if current_version.is_none() { + CandidateDecision::Install + } else { + CandidateDecision::NoUpdate + }); } } if current_version == Some(manifest.product_version.as_str()) { @@ -754,14 +798,6 @@ fn record_failed_sequence( Ok(()) } -fn required_environment(name: &'static str) -> Result { - let value = std::env::var(name).with_context(|| format!("{name} is required"))?; - if value.is_empty() { - bail!("{name} must not be empty"); - } - Ok(value) -} - fn environment_u64(name: &'static str, default: u64) -> Result { let value = match std::env::var(name) { Ok(value) => value @@ -848,22 +884,14 @@ struct ConfiguredRelease { } async fn configured_release() -> Result> { - let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else { - return Ok(None); - }; - let manifest_location = manifest_location - .into_string() - .map_err(|_| anyhow::anyhow!("{MANIFEST_ENV} must be valid UTF-8"))?; - if manifest_location.is_empty() { - bail!("{MANIFEST_ENV} must not be empty"); - } - let signature_location = - std::env::var(SIGNATURE_ENV).unwrap_or_else(|_| format!("{manifest_location}.sig")); - let key_text = std::env::var(PUBLIC_KEY_ENV) - .with_context(|| format!("{PUBLIC_KEY_ENV} is required when {MANIFEST_ENV} is set"))?; - configured_release_from_locations(&manifest_location, &signature_location, &key_text) - .await - .map(Some) + let config = config::load()?; + configured_release_from_locations( + &config[MANIFEST_ENV], + &config[SIGNATURE_ENV], + &config[PUBLIC_KEY_ENV], + ) + .await + .map(Some) } async fn configured_release_from_locations( @@ -990,6 +1018,11 @@ fn current_version(install_root: &Path) -> Result> { .map(str::to_owned)) } +fn current_channel_version(install_root: &Path, channel: &str) -> Result> { + let manifest = read_installed_manifest(&install_root.join("current"))?; + Ok((manifest.channel == channel).then_some(manifest.product_version)) +} + fn read_installed_manifest(release_dir: &Path) -> Result { let manifest_path = release_dir.join("manifest.json"); let bytes = fs::read(&manifest_path).with_context(|| { @@ -1507,6 +1540,7 @@ mod tests { product_version: "1.1.0".into(), daemon_was_active: false, selected: true, + existing_target: false, }); recover_interrupted_activation( &paths, diff --git a/iota-updater/src/main.rs b/iota-updater/src/main.rs index 958cc5e..b9f0baf 100644 --- a/iota-updater/src/main.rs +++ b/iota-updater/src/main.rs @@ -5,7 +5,15 @@ async fn main() -> Result<()> { let command = std::env::args().nth(1).unwrap_or_else(|| "status".into()); match command.as_str() { "check" => println!("{}", iota_updater::check_update().await?), - "status" => println!("updater ready"), + "status" => println!("channel: {}", iota_updater::config::selected_channel()?), + "channel" => { + if let Some(channel) = std::env::args().nth(2) { + iota_updater::config::select_channel(&channel)?; + println!("Selected {channel}. Run check, then apply to update."); + } else { + println!("{}", iota_updater::config::selected_channel()?); + } + } "apply" => { struct StderrObserver; impl iota_updater::UpdateObserver for StderrObserver { @@ -27,7 +35,7 @@ async fn main() -> Result<()> { } _ => { return Err(anyhow::anyhow!( - "usage: iota-updater check|status|apply|rollback VERSION" + "usage: iota-updater check|status|apply|channel [stable|canary]|rollback VERSION" )); } } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 5406cb3..be8a6d0 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -355,6 +355,11 @@ struct UpdateArgs { #[derive(Subcommand, Debug)] enum UpdateAction { Check, + Apply, + Channel { + #[arg(value_parser = ["stable", "canary"])] + channel: Option, + }, } #[derive(Args, Debug)] struct CommunityArgs { @@ -523,6 +528,10 @@ pub enum Command { limit: usize, }, UpdateCheck, + UpdateApply, + UpdateChannel { + channel: Option, + }, CommunityList, TermsStatus { system: bool, @@ -710,6 +719,8 @@ impl CliInvocation { Some(CliCommand::Logs { limit }) => Command::Logs { limit }, Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck, + UpdateAction::Apply => Command::UpdateApply, + UpdateAction::Channel { channel } => Command::UpdateChannel { channel }, }, Some(CliCommand::Community(community)) => match community.action { CommunityAction::List => Command::CommunityList, diff --git a/iota/src/main.rs b/iota/src/main.rs index 807b88b..42563a8 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -69,6 +69,33 @@ async fn run() -> Result<(), StartupError> { iota_installer::bootstrap_linux_bundle(Path::new(&bundle), operator.as_deref()) .map_err(|error| StartupError::Other(format!("Bootstrap failed: {error}"))) } + command @ (Command::UpdateCheck | Command::UpdateApply | Command::UpdateChannel { .. }) => { + let mut updater = std::process::Command::new( + iota_paths::current_version_link().join("bin/iota-updater"), + ); + match command { + Command::UpdateCheck => { + updater.arg("check"); + } + Command::UpdateApply => { + updater.arg("apply"); + } + Command::UpdateChannel { channel } => { + updater.arg("channel"); + if let Some(channel) = channel { + updater.arg(channel); + } + } + _ => unreachable!(), + } + let status = updater + .status() + .map_err(|error| StartupError::Other(format!("Run updater: {error}")))?; + if !status.success() { + return Err(StartupError::Other(format!("Updater failed with {status}"))); + } + Ok(()) + } command => { let endpoints = resolve_endpoints()?; if matches!( @@ -406,6 +433,8 @@ fn print_help() { println!(" components Show component health"); println!(" logs [--limit N] Show recent log entries"); println!(" update check Check for updates"); + println!(" update apply Apply the selected channel's signed update"); + println!(" update channel [stable|canary] Show or select the update channel"); println!(" community list List communities"); println!(" terms status Show terms acceptance status"); println!(" terms show Show a terms document"); @@ -717,6 +746,7 @@ async fn run_command( Command::Components => LocalRequest::ListComponents, Command::Logs { limit } => LocalRequest::GetLogs { limit }, Command::UpdateCheck => LocalRequest::CheckUpdate, + Command::UpdateApply | Command::UpdateChannel { .. } => unreachable!(), Command::CommunityList => LocalRequest::ListCommunities, Command::UsersRelease { confirmed: false, .. diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index 3ea6f05..8d19b14 100644 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -20,6 +20,15 @@ contract="$repository_directory/iota-installer/bundle-files.txt" staging_directory="$(mktemp -d)" trap 'rm -rf "$staging_directory"' EXIT +case "$update_channel" in stable|canary) ;; *) echo "channel must be stable or canary" >&2; exit 2 ;; esac +architecture="$(jq -r '.artifacts[0].architecture' "$release_manifest")" +case "$architecture" in x86_64|aarch64) ;; *) echo "unsupported update architecture" >&2; exit 2 ;; esac +expected_url="https://git.methanium.net/tensamin/prod-pins/releases/download/$update_channel/iota-update-linux-$architecture.json" +if [[ "$update_manifest_url" != "$expected_url" || "$update_signature_url" != "$expected_url.sig" ]]; then + echo "update URLs must match the selected prod-pins channel" >&2 + exit 2 +fi + if [[ ! "$product_version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then echo "product version contains unsupported characters: $product_version" >&2 exit 2 diff --git a/scripts/build-update-manifest.sh b/scripts/build-update-manifest.sh index 40740de..d2874db 100644 --- a/scripts/build-update-manifest.sh +++ b/scripts/build-update-manifest.sh @@ -22,6 +22,16 @@ staging_directory="$(mktemp -d)" artifacts="$staging_directory/artifacts.jsonl" trap 'rm -rf "$staging_directory"' EXIT +case "$channel" in stable|canary) ;; *) echo "channel must be stable or canary" >&2; exit 2 ;; esac +case "$operating_system/$architecture" in linux/x86_64|linux/aarch64) ;; *) echo "unsupported update platform" >&2; exit 2 ;; esac +source_sha="${IOTA_RELEASE_SOURCE_SHA:-$(git -C "$repository_directory" rev-parse HEAD)}" +if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "IOTA_RELEASE_SOURCE_SHA must be a full source commit SHA" >&2 + exit 2 +fi +product_version="${product_version}-${channel}-${source_sha}" +signing_key_id="${IOTA_RELEASE_SIGNING_KEY_ID:-primary}" + if [[ ! "$release_sequence" =~ ^[1-9][0-9]*$ ]]; then echo "release sequence must be a positive integer" >&2 exit 2 @@ -42,7 +52,7 @@ while IFS=$'\t' read -r role artifact_path; do --arg os "$operating_system" \ --arg architecture "$architecture" \ --arg path "$artifact_path" \ - --arg url "$base_url/$asset_name" \ + --arg url "$base_url/$asset_name-$operating_system-$architecture" \ --arg sha256 "$(sha256sum "$source_path" | cut -d ' ' -f 1)" \ --argjson size "$(stat -c %s "$source_path")" \ '{role: $role, os: $os, architecture: $architecture, path: $path, url: $url, sha256: $sha256, size: $size}' \ @@ -56,7 +66,7 @@ jq -s \ --argjson release_sequence "$release_sequence" \ --arg published_at "$published_at" \ --arg expires_at "$expires_at" \ - --arg release_signing_key_id "primary" \ + --arg release_signing_key_id "$signing_key_id" \ '{ product_version: $product_version, channel: $channel, diff --git a/scripts/releases.md b/scripts/releases.md new file mode 100644 index 0000000..581a9ed --- /dev/null +++ b/scripts/releases.md @@ -0,0 +1,84 @@ +# Unmanaged signed releases + +Publishing belongs to prod-pins. Iota only builds and verifies release inputs. + +## Operator commands + +Run as root for the unmanaged system installation: + +```sh +iota update channel stable +iota update channel canary +iota update check +iota update apply +``` + +`iota update channel` shows the selected channel. Selection atomically persists +`/etc/iota/update.env` without fetching or applying a release. The standalone +`iota-updater` supports the same commands. Check and apply read this file directly, +including the pinned public key and key ID. Process environment cannot replace +the trust configuration. Channel selection preserves both trust values and all +per-channel anti-replay state. + +The manifest URL is +`https://git.methanium.net/tensamin/prod-pins/releases/download/CHANNEL/iota-update-linux-ARCH.json`. +The signature URL appends `.sig`. CHANNEL is `stable` or `canary`, ARCH is +`x86_64` or `aarch64`. Apply can select a previously accepted target when the +active release belongs to the other channel. It still rejects older sequences, +sequence/version reuse and failed activation sequences. Same-channel explicit +rollback remains respected. + +The initial ZIP installer remains supported. It preserves existing update.env +and removes a previously provisioned unattended update timer. The update service +is an explicit oneshot apply operation with no timer. + +## Release script contracts + +```sh +bash scripts/build-update-manifest.sh BINARY_DIRECTORY BASE_VERSION CHANNEL RELEASE_SEQUENCE PUBLISHED_AT EXPIRES_AT linux ARCH BASE_URL OUTPUT.json +iota-release sign OUTPUT.json OUTPUT.json.sig +bash scripts/build-release-bundle.sh BINARY_DIRECTORY PRODUCT_VERSION OUTPUT.json MANIFEST_URL PUBLIC_KEY SIGNATURE_URL CHANNEL SIGNING_KEY_ID OUTPUT.zip +iota-bundle OUTPUT.zip +``` + +The binary directory contains `iota`, `iota-daemon` and `iota-updater`. +The manifest script emits PRODUCT_VERSION as `BASE_VERSION-CHANNEL-FULL_SOURCE_SHA`. +Read `.product_version` from the generated manifest for the bundle command. +Use an immutable release BASE_URL in prod-pins. Upload the binaries there as +`iota-linux-ARCH`, `iota-daemon-linux-ARCH` and `iota-updater-linux-ARCH`. +The signed artifact paths remain `bin/iota`, `bin/iota-daemon` and +`bin/iota-updater`, with SHA-256 hashes and byte lengths. + +Publish the manifest and signature under each mutable `stable` or `canary` +release as `iota-update-linux-ARCH.json` and `iota-update-linux-ARCH.json.sig`. +Sequences must increase per channel. Serialize publication per channel and +reject a lower sequence or reuse with a different product version. Keep +immutable binary assets available for already published manifests. + +## Signature and workflow inputs + +`IOTA_RELEASE_SIGNING_KEY` is a secret containing a 32-byte Ed25519 seed as +64 hexadecimal characters. `iota-release sign` prints the derived 32-byte +public key as hex. Compare it with the expected pinned public key before +publishing or building installer trust configuration. + +The detached signature is 64 Ed25519 bytes encoded as 128 hexadecimal +characters. It signs `serde_json::to_vec` of the typed `ReleaseManifest`, in +the Rust declaration field order, with nested artifacts in their declaration +field order. It does not sign the pretty-printed JSON file bytes. Use the Rust +signer rather than a generic JSON canonicalizer. + +The prod-pins workflow needs: + +- `IOTA_RELEASE_SIGNING_KEY`, secret signing seed. +- `IOTA_RELEASE_SOURCE_SHA`, full 40-character Iota source commit SHA. Defaults + to the local repository HEAD, so set it explicitly when building elsewhere. +- `IOTA_RELEASE_SIGNING_KEY_ID`, defaults to `primary`. +- Expected pinned public key, supplied as the bundle PUBLIC_KEY argument. +- Base product version, channel, positive release sequence, RFC 3339 publication + and expiry timestamps, architecture and immutable artifact BASE_URL. +- A Forgejo API token with release upload access to `tensamin/prod-pins`. + +Build the Rust release binaries plus `iota-release` and `iota-bundle`. Shell +scripts require bash, git, coreutils, jq and zip. No Iota publishing workflow +remains; verification runs in `.forgejo/workflows/validate.yml`. diff --git a/systemd/iota-update.timer b/systemd/iota-update.timer deleted file mode 100644 index 367b8c2..0000000 --- a/systemd/iota-update.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Check for Tensamin Iota updates daily - -[Timer] -OnBootSec=15min -OnUnitActiveSec=24h -RandomizedDelaySec=1h -Persistent=true - -[Install] -WantedBy=timers.target