Move builds to prod-pins and add explicit update channels
Some checks failed
Validate authentication / Validate authentication (push) Failing after 1s

This commit is contained in:
Alois 2026-10-04 19:27:02 +02:00
commit e71d9c3118
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
15 changed files with 473 additions and 722 deletions

View file

@ -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"
'

View file

@ -32,3 +32,12 @@ jobs:
- name: Check release binaries - name: Check release binaries
run: nix develop -c cargo check -p iota -p iota-daemon -p iota-updater -p iota-installer --locked 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

155
flake.lock generated
View file

@ -1,21 +1,54 @@
{ {
"nodes": { "nodes": {
"flake-parts": { "client": {
"inputs": { "flake": false,
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": { "locked": {
"lastModified": 1782949081, "lastModified": 1791114583,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "narHash": "sha256-J4j6LI+erWFp+maryBRN08Ra90BKLjv0NOhVC7subEY=",
"owner": "hercules-ci", "ref": "dev",
"repo": "flake-parts", "rev": "d08a00a94acda0d1622de5960fa74750bf7d64db",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", "revCount": 646,
"type": "github" "type": "git",
"url": "ssh://git@methanium.net/tensamin/client"
}, },
"original": { "original": {
"owner": "hercules-ci", "ref": "dev",
"repo": "flake-parts", "type": "git",
"type": "github" "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": { "mtp-type-maps": {
@ -23,68 +56,112 @@
"locked": { "locked": {
"lastModified": 1790572556, "lastModified": 1790572556,
"narHash": "sha256-ugNZR2Be9N9UjG0aVN8Yrk4hYOVC2edjtC9xBhbW35w=", "narHash": "sha256-ugNZR2Be9N9UjG0aVN8Yrk4hYOVC2edjtC9xBhbW35w=",
"ref": "refs/heads/main", "ref": "main",
"rev": "4f18c7a0d9b04d38a77fbb011c4f0b21c25bf7bf", "rev": "4f18c7a0d9b04d38a77fbb011c4f0b21c25bf7bf",
"revCount": 28, "revCount": 28,
"type": "git", "type": "git",
"url": "https://git.methanium.net/tensamin/mtp-type-maps" "url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
}, },
"original": { "original": {
"ref": "main",
"type": "git", "type": "git",
"url": "https://git.methanium.net/tensamin/mtp-type-maps" "url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1784497964, "lastModified": 1790981744,
"narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", "narHash": "sha256-sm6DclXJudZfP/pcDBQQsRqyMxBcQsuw+7yQr4rBBLE=",
"owner": "nixos", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", "rev": "55ba7f49ef2962b42cbd126522b7df5f95037679",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "NixOS",
"ref": "nixos-unstable", "ref": "nixpkgs-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"nixpkgs-lib": { "omega": {
"flake": false,
"locked": { "locked": {
"lastModified": 1782614948, "lastModified": 1791114269,
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", "narHash": "sha256-56Nh5XnH7SK1P0kdtai/ZKcuQr8YlgDo5ndBf67YnFo=",
"owner": "nix-community", "ref": "main",
"repo": "nixpkgs.lib", "rev": "993fa5ead0cfe5f5f0ab1ecd12ffe0f343380489",
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", "revCount": 157,
"type": "github" "type": "git",
"url": "ssh://git@methanium.net/tensamin/omega"
}, },
"original": { "original": {
"owner": "nix-community", "ref": "main",
"repo": "nixpkgs.lib", "type": "git",
"type": "github" "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": { "root": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "prod-pins": "prod-pins"
"mtp-type-maps": "mtp-type-maps",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
} }
}, },
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"prod-pins",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1784526465, "lastModified": 1791101754,
"narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=", "narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b", "rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd",
"type": "github" "type": "github"
}, },
"original": { "original": {

399
flake.nix
View file

@ -1,389 +1,34 @@
{ {
description = "Iota"; description = "Iota development and verification";
inputs = { inputs.prod-pins.url = "git+ssh://git@methanium.net/tensamin/prod-pins?ref=main";
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;
};
};
outputs = outputs = { self, prod-pins, ... }:
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 let
rustPkgs = import nixpkgs { forSystems = prod-pins.inputs.nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
project = system: prod-pins.lib.mkPackages {
inherit system; inherit system;
overlays = [ (import rust-overlay) ]; sources.iota = self;
}; };
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { in {
extensions = [ checks = forSystems (system: { inherit ((project system).packages) iota; });
"rust-src" devShells = forSystems (system: {
"rust-analyzer" default = (project system).devShells.iota.overrideAttrs (old: {
"clippy" TENSAMIN_SOURCE = (project system).packages.iota.transformedSource;
"rustfmt" shellHook = (old.shellHook or "") + ''
]; # Stage authoritative dependencies without changing the checkout.
}; export TENSAMIN_CHECKOUT="$PWD"
commonBuildInputs = with pkgs; [ workRoot="/tmp/tensamin-dev-$UID/$(basename "$TENSAMIN_SOURCE")"
openssl if [ ! -d "$workRoot" ]; then
sqlite mkdir -p "$(dirname "$workRoot")"
]; stage="$(mktemp -d "$workRoot.XXXXXX")"
commonNativeBuildInputs = with pkgs; [ cp -R "$TENSAMIN_SOURCE/." "$stage/" &&
cmake chmod -R u+w "$stage" &&
perl mv -T "$stage" "$workRoot" || exit 1
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 fi
done cd "$workRoot"
''; '';
}); });
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 ];
};
};
};
};
};
} }

View file

@ -5,6 +5,5 @@ systemd/iota-daemon.service
systemd/iota-daemon.socket systemd/iota-daemon.socket
systemd/sysusers.d/iota.conf systemd/sysusers.d/iota.conf
systemd/iota-update.service systemd/iota-update.service
systemd/iota-update.timer
systemd/update.env systemd/update.env
manifest.json manifest.json

View file

@ -31,6 +31,14 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
let product_version = product_version(staging.path())?; let product_version = product_version(staging.path())?;
validate_update_environment(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"))?; render_daemon_service(&staging.path().join("systemd/iota-daemon.service"))?;
let version_dir = format!( let version_dir = format!(
@ -267,6 +275,9 @@ fn validate_update_environment(staging: &Path) -> Result<()> {
if entries.len() != 5 { if entries.len() != 5 {
bail!("updater environment contains unexpected variables"); 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 let public_key = entries
.get("IOTA_UPDATE_PUBLIC_KEY") .get("IOTA_UPDATE_PUBLIC_KEY")
.context("updater environment is missing 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() { fn rejects_bundle_missing_contract_member() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let bundle = directory.path().join("release.zip"); 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(); 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] #[test]

View file

@ -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<String> {
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<BTreeMap<String, String>> {
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<String> {
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(())
}

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod manifest; pub mod manifest;
pub mod transaction; pub mod transaction;
@ -80,9 +81,10 @@ struct UpdatePolicy {
impl UpdatePolicy { impl UpdatePolicy {
fn from_environment() -> Result<Self> { fn from_environment() -> Result<Self> {
let config = config::load()?;
Ok(Self { Ok(Self {
channel: required_environment(CHANNEL_ENV)?, channel: config[CHANNEL_ENV].clone(),
signing_key_id: required_environment(SIGNING_KEY_ID_ENV)?, signing_key_id: config[SIGNING_KEY_ID_ENV].clone(),
activation: ActivationPolicy::from_environment()?, activation: ActivationPolicy::from_environment()?,
}) })
} }
@ -105,6 +107,8 @@ struct PendingActivation {
product_version: String, product_version: String,
daemon_was_active: bool, daemon_was_active: bool,
selected: bool, selected: bool,
#[serde(default)]
existing_target: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@ -172,21 +176,21 @@ impl DaemonSupervisor for SystemdSupervisor {
} }
pub async fn check_update() -> Result<bool> { pub async fn check_update() -> Result<bool> {
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 { let Some(release) = configured_release().await? else {
return Ok(false); 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)?; let state = load_or_initialize_update_state(&paths)?;
if state.pending_activation.is_some() { if state.pending_activation.is_some() {
bail!("an interrupted update requires iota-updater apply recovery"); bail!("an interrupted update requires iota-updater apply recovery");
} }
let decision = evaluate_candidate( let decision = evaluate_candidate(
&release.manifest, &release.manifest,
current_version(&paths.install_root)?.as_deref(), current_channel_version(&paths.install_root, &policy.channel)?.as_deref(),
state.channels.get(&policy.channel), state.channels.get(&policy.channel),
&policy.channel, &policy.channel,
&policy.signing_key_id, &policy.signing_key_id,
@ -214,6 +218,12 @@ async fn apply_update_with_observer(
) -> Result<bool> { ) -> Result<bool> {
let transaction = UpdateTransaction::from_paths(paths)?; let transaction = UpdateTransaction::from_paths(paths)?;
let _lock = transaction.acquire()?; 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)?; let mut state = load_or_initialize_update_state(paths)?;
recover_interrupted_activation( recover_interrupted_activation(
paths, paths,
@ -228,7 +238,7 @@ async fn apply_update_with_observer(
}; };
let decision = evaluate_candidate( let decision = evaluate_candidate(
&release.manifest, &release.manifest,
current_version(&paths.install_root)?.as_deref(), current_channel_version(&paths.install_root, &policy.channel)?.as_deref(),
state.channels.get(&policy.channel), state.channels.get(&policy.channel),
&policy.channel, &policy.channel,
&policy.signing_key_id, &policy.signing_key_id,
@ -281,7 +291,24 @@ async fn apply_update_with_observer(
UpdatePhase::ActivationStarted, UpdatePhase::ActivationStarted,
Some(&release.manifest.product_version), 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, Ok(activation) => activation,
Err(error) => { Err(error) => {
remove_unselected_candidate(&state, &transaction.root)?; remove_unselected_candidate(&state, &transaction.root)?;
@ -395,7 +422,7 @@ fn remove_unselected_candidate(state: &UpdateState, install_root: &Path) -> Resu
return Ok(()); return Ok(());
}; };
validate_pending_candidate(pending, install_root)?; 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(|| { fs::remove_dir_all(&pending.new_target).with_context(|| {
format!( format!(
"remove unselected candidate release {}", "remove unselected candidate release {}",
@ -430,8 +457,20 @@ fn begin_activation_state(
.root .root
.join("versions") .join("versions")
.join(&manifest.product_version); .join(&manifest.product_version);
if new_target.exists() { let existing_target = new_target.exists();
bail!("release version already exists: {}", new_target.display()); 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(); let mut updated = state.clone();
updated.pending_activation = Some(PendingActivation { updated.pending_activation = Some(PendingActivation {
@ -442,6 +481,7 @@ fn begin_activation_state(
product_version: manifest.product_version.clone(), product_version: manifest.product_version.clone(),
daemon_was_active, daemon_was_active,
selected: true, selected: true,
existing_target,
}); });
save_update_state(&paths.update_status_file(), &updated)?; save_update_state(&paths.update_status_file(), &updated)?;
*state = updated; *state = updated;
@ -603,7 +643,11 @@ fn evaluate_candidate(
); );
} }
if manifest.release_sequence == state.highest_accepted_sequence { 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()) { if current_version == Some(manifest.product_version.as_str()) {
@ -754,14 +798,6 @@ fn record_failed_sequence(
Ok(()) Ok(())
} }
fn required_environment(name: &'static str) -> Result<String> {
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<u64> { fn environment_u64(name: &'static str, default: u64) -> Result<u64> {
let value = match std::env::var(name) { let value = match std::env::var(name) {
Ok(value) => value Ok(value) => value
@ -848,20 +884,12 @@ struct ConfiguredRelease {
} }
async fn configured_release() -> Result<Option<ConfiguredRelease>> { async fn configured_release() -> Result<Option<ConfiguredRelease>> {
let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else { let config = config::load()?;
return Ok(None); configured_release_from_locations(
}; &config[MANIFEST_ENV],
let manifest_location = manifest_location &config[SIGNATURE_ENV],
.into_string() &config[PUBLIC_KEY_ENV],
.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 .await
.map(Some) .map(Some)
} }
@ -990,6 +1018,11 @@ fn current_version(install_root: &Path) -> Result<Option<String>> {
.map(str::to_owned)) .map(str::to_owned))
} }
fn current_channel_version(install_root: &Path, channel: &str) -> Result<Option<String>> {
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<ReleaseManifest> { fn read_installed_manifest(release_dir: &Path) -> Result<ReleaseManifest> {
let manifest_path = release_dir.join("manifest.json"); let manifest_path = release_dir.join("manifest.json");
let bytes = fs::read(&manifest_path).with_context(|| { let bytes = fs::read(&manifest_path).with_context(|| {
@ -1507,6 +1540,7 @@ mod tests {
product_version: "1.1.0".into(), product_version: "1.1.0".into(),
daemon_was_active: false, daemon_was_active: false,
selected: true, selected: true,
existing_target: false,
}); });
recover_interrupted_activation( recover_interrupted_activation(
&paths, &paths,

View file

@ -5,7 +5,15 @@ async fn main() -> Result<()> {
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into()); let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
match command.as_str() { match command.as_str() {
"check" => println!("{}", iota_updater::check_update().await?), "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" => { "apply" => {
struct StderrObserver; struct StderrObserver;
impl iota_updater::UpdateObserver for StderrObserver { impl iota_updater::UpdateObserver for StderrObserver {
@ -27,7 +35,7 @@ async fn main() -> Result<()> {
} }
_ => { _ => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"usage: iota-updater check|status|apply|rollback VERSION" "usage: iota-updater check|status|apply|channel [stable|canary]|rollback VERSION"
)); ));
} }
} }

View file

@ -355,6 +355,11 @@ struct UpdateArgs {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum UpdateAction { enum UpdateAction {
Check, Check,
Apply,
Channel {
#[arg(value_parser = ["stable", "canary"])]
channel: Option<String>,
},
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
struct CommunityArgs { struct CommunityArgs {
@ -523,6 +528,10 @@ pub enum Command {
limit: usize, limit: usize,
}, },
UpdateCheck, UpdateCheck,
UpdateApply,
UpdateChannel {
channel: Option<String>,
},
CommunityList, CommunityList,
TermsStatus { TermsStatus {
system: bool, system: bool,
@ -710,6 +719,8 @@ impl CliInvocation {
Some(CliCommand::Logs { limit }) => Command::Logs { limit }, Some(CliCommand::Logs { limit }) => Command::Logs { limit },
Some(CliCommand::Update(update)) => match update.action { Some(CliCommand::Update(update)) => match update.action {
UpdateAction::Check => Command::UpdateCheck, UpdateAction::Check => Command::UpdateCheck,
UpdateAction::Apply => Command::UpdateApply,
UpdateAction::Channel { channel } => Command::UpdateChannel { channel },
}, },
Some(CliCommand::Community(community)) => match community.action { Some(CliCommand::Community(community)) => match community.action {
CommunityAction::List => Command::CommunityList, CommunityAction::List => Command::CommunityList,

View file

@ -69,6 +69,33 @@ async fn run() -> Result<(), StartupError> {
iota_installer::bootstrap_linux_bundle(Path::new(&bundle), operator.as_deref()) iota_installer::bootstrap_linux_bundle(Path::new(&bundle), operator.as_deref())
.map_err(|error| StartupError::Other(format!("Bootstrap failed: {error}"))) .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 => { command => {
let endpoints = resolve_endpoints()?; let endpoints = resolve_endpoints()?;
if matches!( if matches!(
@ -406,6 +433,8 @@ fn print_help() {
println!(" components Show component health"); println!(" components Show component health");
println!(" logs [--limit N] Show recent log entries"); println!(" logs [--limit N] Show recent log entries");
println!(" update check Check for updates"); 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!(" community list List communities");
println!(" terms status Show terms acceptance status"); println!(" terms status Show terms acceptance status");
println!(" terms show <DOC> Show a terms document"); println!(" terms show <DOC> Show a terms document");
@ -717,6 +746,7 @@ async fn run_command(
Command::Components => LocalRequest::ListComponents, Command::Components => LocalRequest::ListComponents,
Command::Logs { limit } => LocalRequest::GetLogs { limit }, Command::Logs { limit } => LocalRequest::GetLogs { limit },
Command::UpdateCheck => LocalRequest::CheckUpdate, Command::UpdateCheck => LocalRequest::CheckUpdate,
Command::UpdateApply | Command::UpdateChannel { .. } => unreachable!(),
Command::CommunityList => LocalRequest::ListCommunities, Command::CommunityList => LocalRequest::ListCommunities,
Command::UsersRelease { Command::UsersRelease {
confirmed: false, .. confirmed: false, ..

View file

@ -20,6 +20,15 @@ contract="$repository_directory/iota-installer/bundle-files.txt"
staging_directory="$(mktemp -d)" staging_directory="$(mktemp -d)"
trap 'rm -rf "$staging_directory"' EXIT 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 if [[ ! "$product_version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then
echo "product version contains unsupported characters: $product_version" >&2 echo "product version contains unsupported characters: $product_version" >&2
exit 2 exit 2

View file

@ -22,6 +22,16 @@ staging_directory="$(mktemp -d)"
artifacts="$staging_directory/artifacts.jsonl" artifacts="$staging_directory/artifacts.jsonl"
trap 'rm -rf "$staging_directory"' EXIT 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 if [[ ! "$release_sequence" =~ ^[1-9][0-9]*$ ]]; then
echo "release sequence must be a positive integer" >&2 echo "release sequence must be a positive integer" >&2
exit 2 exit 2
@ -42,7 +52,7 @@ while IFS=$'\t' read -r role artifact_path; do
--arg os "$operating_system" \ --arg os "$operating_system" \
--arg architecture "$architecture" \ --arg architecture "$architecture" \
--arg path "$artifact_path" \ --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)" \ --arg sha256 "$(sha256sum "$source_path" | cut -d ' ' -f 1)" \
--argjson size "$(stat -c %s "$source_path")" \ --argjson size "$(stat -c %s "$source_path")" \
'{role: $role, os: $os, architecture: $architecture, path: $path, url: $url, sha256: $sha256, size: $size}' \ '{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" \ --argjson release_sequence "$release_sequence" \
--arg published_at "$published_at" \ --arg published_at "$published_at" \
--arg expires_at "$expires_at" \ --arg expires_at "$expires_at" \
--arg release_signing_key_id "primary" \ --arg release_signing_key_id "$signing_key_id" \
'{ '{
product_version: $product_version, product_version: $product_version,
channel: $channel, channel: $channel,

84
scripts/releases.md Normal file
View file

@ -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`.

View file

@ -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