Centralize Tensamin packages modules and release automation
This commit is contained in:
parent
423ee32a37
commit
123205c97d
28 changed files with 32292 additions and 10 deletions
85
.forgejo/workflows/canary.yml
Normal file
85
.forgejo/workflows/canary.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
name: Canary release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tensamin-central-releases
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: nixos
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
|
FORGEJO_SERVER_URL: ${{ forgejo.server_url }}
|
||||||
|
FORGEJO_REPOSITORY: ${{ forgejo.repository }}
|
||||||
|
FORGEJO_RUN_ID: ${{ forgejo.run_id }}
|
||||||
|
RELEASE_CHANNEL: canary
|
||||||
|
RELEASE_TOKEN: ${{ secrets.TENSAMIN_RELEASE_TOKEN }}
|
||||||
|
FORGEJO_REGISTRY_USER: ${{ vars.FORGEJO_REGISTRY_USER }}
|
||||||
|
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
|
||||||
|
IOTA_RELEASE_PUBLIC_KEY: ${{ vars.IOTA_RELEASE_PUBLIC_KEY }}
|
||||||
|
IOTA_RELEASE_SIGNING_KEY_ID: ${{ vars.IOTA_RELEASE_SIGNING_KEY_ID || 'primary' }}
|
||||||
|
IOTA_BASE_VERSION: ${{ vars.IOTA_BASE_VERSION || '0.1.0' }}
|
||||||
|
TENSAMIN_SSH_KNOWN_HOSTS: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Bootstrap pinned tools and source credentials
|
||||||
|
uses: ./.forgejo/workflows/source-access
|
||||||
|
with:
|
||||||
|
source-key: ${{ secrets.TENSAMIN_SOURCE_SSH_KEY }}
|
||||||
|
known-hosts: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
|
||||||
|
- name: Set immutable release identity
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'RELEASE_SEQUENCE=%s\nRELEASE_TAG=canary-%s-%s\n' "$(date +%s)" "$(git rev-parse HEAD)" "$FORGEJO_RUN_ID" >> "$GITHUB_ENV"
|
||||||
|
- name: Check central release scripts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command shellcheck scripts/release-build.sh scripts/release-client.sh
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command ruff check scripts/release.py scripts/deploy-release.py
|
||||||
|
- name: Validate and build all central packages and client assets
|
||||||
|
run: |
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-out
|
||||||
|
- name: Attempt aarch64 build with available builders
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-arm aarch64-linux > arm-build.log 2>&1; then
|
||||||
|
printf 'aarch64 unavailable. See the attached arm-build.log.\n' > release-out/aarch64-unavailable.txt
|
||||||
|
cp arm-build.log release-out/
|
||||||
|
else
|
||||||
|
cp release-arm/*linux-aarch64* release-out/
|
||||||
|
cp release-arm/*arm64* release-out/
|
||||||
|
cp release-arm/electron-release-metadata-aarch64.json release-out/
|
||||||
|
fi
|
||||||
|
- name: Sign manifests and validate installer bundles
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py sign --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py provenance --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
|
||||||
|
- name: Preserve combined build artifacts
|
||||||
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: combined-release
|
||||||
|
path: release-out/
|
||||||
|
- name: Stage and verify draft attachments
|
||||||
|
run: |
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py stage --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
|
||||||
|
- name: Publish canary and update channel pointers
|
||||||
|
run: |
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py publish --channel canary --tag "$RELEASE_TAG"
|
||||||
|
- name: Remove source credentials
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -n ${SSH_AGENT_PID:-} ]]; then kill "$SSH_AGENT_PID"; fi
|
||||||
|
rm -rf "$RUNNER_TEMP/tensamin-source"
|
||||||
47
.forgejo/workflows/metadata.yml
Normal file
47
.forgejo/workflows/metadata.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
name: Refresh signed metadata
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 4 * * *'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tensamin-central-releases
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
runs-on: nixos
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
|
FORGEJO_SERVER_URL: ${{ forgejo.server_url }}
|
||||||
|
FORGEJO_REPOSITORY: ${{ forgejo.repository }}
|
||||||
|
FORGEJO_RUN_ID: ${{ forgejo.run_id }}
|
||||||
|
RELEASE_TOKEN: ${{ secrets.TENSAMIN_RELEASE_TOKEN }}
|
||||||
|
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
|
||||||
|
IOTA_RELEASE_PUBLIC_KEY: ${{ vars.IOTA_RELEASE_PUBLIC_KEY }}
|
||||||
|
IOTA_RELEASE_SIGNING_KEY_ID: ${{ vars.IOTA_RELEASE_SIGNING_KEY_ID || 'primary' }}
|
||||||
|
IOTA_BASE_VERSION: ${{ vars.IOTA_BASE_VERSION || '0.1.0' }}
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Bootstrap pinned tools and source credentials
|
||||||
|
uses: ./.forgejo/workflows/source-access
|
||||||
|
with:
|
||||||
|
source-key: ${{ secrets.TENSAMIN_SOURCE_SSH_KEY }}
|
||||||
|
known-hosts: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
|
||||||
|
- name: Refresh signed metadata
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
root=$PWD
|
||||||
|
revision=$(git rev-parse HEAD)
|
||||||
|
for channel in stable canary; do
|
||||||
|
git checkout --detach "$revision"
|
||||||
|
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; }" --command python3 scripts/release.py refresh --channel "$channel" --directory "$RUNNER_TEMP/refresh-$FORGEJO_RUN_ID-$channel"
|
||||||
|
done
|
||||||
|
- name: Remove source credentials
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -n ${SSH_AGENT_PID:-} ]]; then kill "$SSH_AGENT_PID"; fi
|
||||||
|
rm -rf "$RUNNER_TEMP/tensamin-source"
|
||||||
49
.forgejo/workflows/source-access/action.yml
Normal file
49
.forgejo/workflows/source-access/action.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: Pinned source access
|
||||||
|
description: Bootstrap public pinned tools before preparing private Nix source access
|
||||||
|
inputs:
|
||||||
|
source-key:
|
||||||
|
description: SSH key with read access to locked sources
|
||||||
|
required: true
|
||||||
|
known-hosts:
|
||||||
|
description: Verified SSH host keys
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Bootstrap tools from the public nixpkgs lock
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Nix interpolation must remain literal for the evaluator.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
tools=$(nix build --impure --no-link --print-out-paths --expr '
|
||||||
|
let
|
||||||
|
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||||
|
source = builtins.fetchTree lock.nodes.${lock.nodes.root.inputs.nixpkgs}.locked;
|
||||||
|
pkgs = import source { system = builtins.currentSystem; };
|
||||||
|
in pkgs.buildEnv {
|
||||||
|
name = "release-bootstrap";
|
||||||
|
paths = with pkgs; [ git openssh python3 bash coreutils ];
|
||||||
|
}')
|
||||||
|
printf '%s/bin\n' "$tools" >> "$GITHUB_PATH"
|
||||||
|
- name: Prepare SSH and checkout fetch credentials
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SOURCE_KEY: ${{ inputs.source-key }}
|
||||||
|
KNOWN_HOSTS: ${{ inputs.known-hosts }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
home="$RUNNER_TEMP/tensamin-source"
|
||||||
|
mkdir -p "$home/.ssh"
|
||||||
|
printf '%s\n' "$SOURCE_KEY" > "$home/.ssh/key"
|
||||||
|
printf '%s\n' "$KNOWN_HOSTS" > "$home/.ssh/known_hosts"
|
||||||
|
printf 'Host *\n IdentityFile "%s/.ssh/key"\n IdentitiesOnly yes\n StrictHostKeyChecking yes\n UserKnownHostsFile "%s/.ssh/known_hosts"\n BatchMode yes\n' "$home" "$home" > "$home/.ssh/config"
|
||||||
|
# An agent also supports Nix versions using libgit2 instead of Git's SSH command.
|
||||||
|
eval "$(ssh-agent -s)"
|
||||||
|
ssh-add "$home/.ssh/key"
|
||||||
|
# The askpass process reads the token when Git invokes it.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
printf '#!/bin/sh\ncase "$1" in *Username*) printf "%%s\\n" token;; *) printf "%%s\\n" "$RELEASE_TOKEN";; esac\n' > "$home/askpass"
|
||||||
|
chmod 700 "$home/askpass"
|
||||||
|
printf 'HOME=%s\nGIT_SSH_COMMAND=ssh -F "%s/.ssh/config"\nGIT_ASKPASS=%s/askpass\nGIT_TERMINAL_PROMPT=0\nSSH_AUTH_SOCK=%s\nSSH_AGENT_PID=%s\n' "$home" "$home" "$home" "$SSH_AUTH_SOCK" "$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||||
100
.forgejo/workflows/stable.yml
Normal file
100
.forgejo/workflows/stable.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
name: Stable release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
canary_tag:
|
||||||
|
description: Successful immutable canary tag to promote
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tensamin-central-releases
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: nixos
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
|
FORGEJO_SERVER_URL: ${{ forgejo.server_url }}
|
||||||
|
FORGEJO_REPOSITORY: ${{ forgejo.repository }}
|
||||||
|
FORGEJO_RUN_ID: ${{ forgejo.run_id }}
|
||||||
|
RELEASE_CHANNEL: stable
|
||||||
|
RELEASE_TOKEN: ${{ secrets.TENSAMIN_RELEASE_TOKEN }}
|
||||||
|
FORGEJO_REGISTRY_USER: ${{ vars.FORGEJO_REGISTRY_USER }}
|
||||||
|
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
|
||||||
|
IOTA_RELEASE_PUBLIC_KEY: ${{ vars.IOTA_RELEASE_PUBLIC_KEY }}
|
||||||
|
IOTA_RELEASE_SIGNING_KEY_ID: ${{ vars.IOTA_RELEASE_SIGNING_KEY_ID || 'primary' }}
|
||||||
|
IOTA_BASE_VERSION: ${{ vars.IOTA_BASE_VERSION || '0.1.0' }}
|
||||||
|
NIXOS_FLAKE_REPOSITORY: ${{ vars.NIXOS_FLAKE_REPOSITORY }}
|
||||||
|
NIXOS_FLAKE_BRANCH: ${{ vars.NIXOS_FLAKE_BRANCH || 'main' }}
|
||||||
|
NIXOS_FLAKE_WRITE_TOKEN: ${{ secrets.NIXOS_FLAKE_WRITE_TOKEN }}
|
||||||
|
TENSAMIN_PROD_DEPLOY_SSH_KEY: ${{ secrets.TENSAMIN_PROD_DEPLOY_SSH_KEY }}
|
||||||
|
TENSAMIN_PROD_DEPLOY_HOST: ${{ vars.TENSAMIN_PROD_DEPLOY_HOST }}
|
||||||
|
TENSAMIN_PROD_DEPLOY_PORT: ${{ vars.TENSAMIN_PROD_DEPLOY_PORT || '22' }}
|
||||||
|
TENSAMIN_PROD_DEPLOY_JUMP_HOST: ${{ vars.TENSAMIN_PROD_DEPLOY_JUMP_HOST }}
|
||||||
|
TENSAMIN_PROD_DEPLOY_JUMP_PORT: ${{ vars.TENSAMIN_PROD_DEPLOY_JUMP_PORT || '7930' }}
|
||||||
|
TENSAMIN_SSH_KNOWN_HOSTS: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Bootstrap pinned tools and source credentials
|
||||||
|
uses: ./.forgejo/workflows/source-access
|
||||||
|
with:
|
||||||
|
source-key: ${{ secrets.TENSAMIN_SOURCE_SSH_KEY }}
|
||||||
|
known-hosts: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
|
||||||
|
- name: Select successful canary for stable
|
||||||
|
env:
|
||||||
|
CANARY_TAG: ${{ inputs.canary_tag }}
|
||||||
|
run: python3 scripts/release.py select --tag "$CANARY_TAG"
|
||||||
|
- name: Set immutable release identity
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'RELEASE_SEQUENCE=%s\nRELEASE_TAG=stable-%s-%s\n' "$(date +%s)" "$(git rev-parse HEAD)" "$FORGEJO_RUN_ID" >> "$GITHUB_ENV"
|
||||||
|
- name: Check central release scripts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command shellcheck scripts/release-build.sh scripts/release-client.sh
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command ruff check scripts/release.py scripts/deploy-release.py
|
||||||
|
- name: Validate and build all central packages and client assets
|
||||||
|
run: |
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-out
|
||||||
|
- name: Attempt aarch64 build with available builders
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-arm aarch64-linux > arm-build.log 2>&1; then
|
||||||
|
printf 'aarch64 unavailable. See the attached arm-build.log.\n' > release-out/aarch64-unavailable.txt
|
||||||
|
cp arm-build.log release-out/
|
||||||
|
else
|
||||||
|
cp release-arm/*linux-aarch64* release-out/
|
||||||
|
cp release-arm/*arm64* release-out/
|
||||||
|
cp release-arm/electron-release-metadata-aarch64.json release-out/
|
||||||
|
fi
|
||||||
|
- name: Sign manifests and validate installer bundles
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py sign --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py provenance --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
|
||||||
|
- name: Preserve combined build artifacts
|
||||||
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: combined-release
|
||||||
|
path: release-out/
|
||||||
|
- name: Stage and verify draft attachments
|
||||||
|
run: |
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py stage --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
|
||||||
|
- name: Commit infrastructure lock, deploy, check health and publish stable
|
||||||
|
run: |
|
||||||
|
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/deploy-release.py
|
||||||
|
- name: Remove source credentials
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -n ${SSH_AGENT_PID:-} ]]; then kill "$SSH_AGENT_PID"; fi
|
||||||
|
rm -rf "$RUNNER_TEMP/tensamin-source"
|
||||||
162
README.md
162
README.md
|
|
@ -1,3 +1,161 @@
|
||||||
# Tensamin Production Pins
|
# Tensamin production pins
|
||||||
|
|
||||||
Pins for the Client, Iota, Omikron, Omega and MTP (incl. Type Maps) for a consistent production environment
|
Shared sources and builds for the client, Iota, Omikron, Omega, MTP, and type maps.
|
||||||
|
|
||||||
|
## Build usage
|
||||||
|
|
||||||
|
Linux x86_64 and aarch64 packages use one nixpkgs, Rust toolchain, MTP source,
|
||||||
|
and type-map source. The default package is the Electron client.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .
|
||||||
|
nix run .#client
|
||||||
|
nix build .#client-web
|
||||||
|
nix build .#iota .#iota-daemon .#iota-ui .#omikron .#omega
|
||||||
|
nix develop .#iota
|
||||||
|
nix develop .#client
|
||||||
|
```
|
||||||
|
|
||||||
|
`client-web` contains the static site at its output root. `iota` contains
|
||||||
|
`iota`, `iota-daemon`, `iota-updater`, `iota-release`, and `iota-bundle`. `iota-ui` exposes
|
||||||
|
the terminal UI as `bin/iota-ui`. `iota-bundle` adds upstream systemd files,
|
||||||
|
release scripts, and artifact contracts under `share/iota`. Signed release
|
||||||
|
manifests and deployment-specific update URLs must be supplied to those scripts.
|
||||||
|
`iota-portable` builds musl-static binaries using the same sources and Cargo
|
||||||
|
vendor dependencies. Releases use these binaries for ordinary Linux, with no
|
||||||
|
Nix installation required. `share/iota/web` contains the pinned static assets.
|
||||||
|
`iota-container`, `omikron-container` and `omega-container` produce Docker-loadable images.
|
||||||
|
Mount runtime configuration, identity files, and certificates in their working
|
||||||
|
directories, `/var/lib/omikron` and `/var/lib/omega`.
|
||||||
|
|
||||||
|
`mtp-sdk` builds the SDK from the shared MTP source.
|
||||||
|
|
||||||
|
Iota's image runs the daemon as UID/GID 1000 and persists `/var/lib/iota`.
|
||||||
|
Bind mounts must be writable by that user. Supply writable configuration at
|
||||||
|
`/var/lib/iota/config/config.yaml`, with `web.mode: network`, `web.bind: 0.0.0.0`,
|
||||||
|
`web.port: 1984`, and `web.certificate`/`web.key` pointing to mounted TLS files.
|
||||||
|
Publish both `1984/tcp` and `1984/udp`. Review and accept terms interactively
|
||||||
|
with `docker exec -it CONTAINER /bin/iota terms accept`, then restart the
|
||||||
|
container. Use `--restart on-failure` to handle the daemon's restart exit 75.
|
||||||
|
Images update by pulling a new release and recreating the container.
|
||||||
|
|
||||||
|
## Updating builds
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#update-all
|
||||||
|
# Recalculate dependency locks and hashes without moving source inputs:
|
||||||
|
nix run .#update-all -- --no-update
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this from the checkout with SSH access to `git@methanium.net`. The command
|
||||||
|
supplies Cargo, pnpm, Python, Git, and Nix. It refreshes `flake.lock`, resolves
|
||||||
|
the transformed Cargo and pnpm dependency graphs into `packages/locks`, and
|
||||||
|
rebuilds every dependency fetcher to verify `packages/hashes.nix`. Review all
|
||||||
|
generated changes and build the affected packages before committing.
|
||||||
|
|
||||||
|
MTP git dependencies become local paths to the shared input before vendoring.
|
||||||
|
Omega's identity crate comes from the same Iota input as the Iota binaries.
|
||||||
|
Both YAML and Rust type-map includes come from `mtp-type-maps`. The SDK and
|
||||||
|
WASM compile from MTP source, with WASM generated for these same maps. The
|
||||||
|
packaged Vite plugin reuses that WASM and still generates JavaScript type maps.
|
||||||
|
Client dependency manifests and pnpm overrides cannot select a release SDK.
|
||||||
|
|
||||||
|
Project shells are `iota`, `omikron`, `omega`, `mtp`, and `client`.
|
||||||
|
The default shell supplies `update-all`. Android/Tauri shells are a followup.
|
||||||
|
|
||||||
|
## Source overrides for CI
|
||||||
|
|
||||||
|
Override raw source inputs when building current project sources:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#iota --override-input iota path:../iota
|
||||||
|
```
|
||||||
|
|
||||||
|
Packages expose `passthru.source`; Rust packages also expose
|
||||||
|
`passthru.transformedSource`, `passthru.mtp`, and `passthru.mtp-type-maps`.
|
||||||
|
`lib.mkPackages { system = "x86_64-linux"; sources = { iota = ../iota; }; }`
|
||||||
|
returns `packages` and `devShells`, and accepts a replacement `hashes` attrset.
|
||||||
|
Dependency-changing overrides require regenerated locks and vendor hashes.
|
||||||
|
Use `update-all` in a disposable checkout with the desired input overrides.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#update-all -- --no-update --override-input iota path:../iota
|
||||||
|
```
|
||||||
|
|
||||||
|
## NixOS modules
|
||||||
|
|
||||||
|
Add this flake as `inputs.tensamin`, pass `inputs` through `specialArgs`, and
|
||||||
|
import the combined module:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{ inputs, pkgs, ... }: {
|
||||||
|
imports = [ inputs.tensamin.nixosModules.default ];
|
||||||
|
environment.systemPackages = [
|
||||||
|
inputs.tensamin.packages.${pkgs.stdenv.hostPlatform.system}.client
|
||||||
|
];
|
||||||
|
tensamin.client.enable = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Individual imports are `nixosModules.iota`, `.omikron`, `.omega`, and `.client`.
|
||||||
|
All options live under `tensamin.*`, and services are disabled by default.
|
||||||
|
The client module serves `client-web` through nginx on `127.0.0.1:8080` by
|
||||||
|
default; installing Electron is separate. Public TLS and Anubis routing belong
|
||||||
|
to the infrastructure. Production routes host nginx through host Anubis and
|
||||||
|
a loopback origin to nginx in the internal production VM.
|
||||||
|
|
||||||
|
Iota requires TLS for enabled listeners. Omikron and Omega require runtime
|
||||||
|
certificates; Omikron also needs its ID and Omega trust bundle, and Omega
|
||||||
|
needs `DB_URL`. Use runtime path strings for secrets. See
|
||||||
|
[modules/README.md](modules/README.md) for options and state handling.
|
||||||
|
New module files must be present in the Git-backed flake source before consumers
|
||||||
|
can import them; local `path:` evaluation includes untracked files.
|
||||||
|
|
||||||
|
## Releases and updates
|
||||||
|
|
||||||
|
Combined publication belongs to this repository. Stable and canary releases
|
||||||
|
should contain artifacts built from the same pinned graph. Client dev builds
|
||||||
|
are local and verification-only, with no dev releases. Android IDs are
|
||||||
|
`net.tensamin.client`, `net.tensamin.client.canary`, and
|
||||||
|
`net.tensamin.client.dev`; canary uses yellow icons and dev uses blue outline
|
||||||
|
branding. Android signing identity must stay consistent and version codes
|
||||||
|
must increase per application ID.
|
||||||
|
|
||||||
|
Images publish to the Forgejo registry as
|
||||||
|
`SERVER/tensamin/SERVICE:IMMUTABLE_TAG-ARCH`, including `iota`, `omikron`, and
|
||||||
|
`omega`. Local image revision tags differ from the immutable publication tags.
|
||||||
|
See [scripts/releases.md](scripts/releases.md) for workflow configuration.
|
||||||
|
|
||||||
|
For unmanaged per-user Linux, extract `iota-portable-linux-ARCH.tar.gz` and run
|
||||||
|
`./bin/iota`. The CLI discovers its sibling daemon and packaged assets. User
|
||||||
|
IPC defaults to `$XDG_RUNTIME_DIR/iota/iota.sock`, or
|
||||||
|
`$XDG_STATE_HOME/iota/iota.sock`, with `~/.local/state` as the fallback.
|
||||||
|
Explicit absolute `IOTA_SOCKET` and `IOTA_DATA_ROOT` overrides still work.
|
||||||
|
TLS clients use the host CA trust store.
|
||||||
|
|
||||||
|
Unmanaged system-wide Linux Iota uses CLI bootstrap with a trusted signed bundle, then
|
||||||
|
explicit `iota update channel stable`, `iota update check`, and
|
||||||
|
`iota update apply` operations. Trust lives in `/etc/iota/update.env` and
|
||||||
|
cannot be replaced by process environment. The pinned upstream bootstrap
|
||||||
|
enables `iota-update.timer`; disable it with
|
||||||
|
`sudo systemctl disable --now iota-update.timer` for manual-only updates.
|
||||||
|
NixOS deployments update through their infrastructure flake.
|
||||||
|
|
||||||
|
The infrastructure and this flake have separate nixpkgs locks. Refresh both
|
||||||
|
when required for security fixes, then build and deploy the affected outputs.
|
||||||
|
`update-all` verifies dependency fetchers, not full application builds or live
|
||||||
|
deployment. Infrastructure `lunitely update` pulls published configuration
|
||||||
|
changes; `lunitely rebuild` activates the local checkout without pulling.
|
||||||
|
`lunitely boot` and `update --boot` stage the next boot without rebooting.
|
||||||
|
|
||||||
|
The production deploy wrapper pins an exact prod-pins SHA, rebuilds with
|
||||||
|
`--no-update-lock-file`, checks health, and restores the previous generation on
|
||||||
|
failure. It does not roll back mutable application data. Record successful
|
||||||
|
runtime pins in the infrastructure lock for later administrator updates.
|
||||||
|
|
||||||
|
See the documentation site's [deployment](https://docs.tensamin.net/deployment/),
|
||||||
|
[updates](https://docs.tensamin.net/updates/), and
|
||||||
|
[release guide](https://docs.tensamin.net/developers/releases/).
|
||||||
|
Its Obtainium configuration uses a real JSON import and explicit self-hosted
|
||||||
|
Forgejo source override. Sync it with release titles and APK layout after the
|
||||||
|
central publication pipeline is finalized.
|
||||||
|
|
|
||||||
156
flake.lock
generated
Normal file
156
flake.lock
generated
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"client": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791114583,
|
||||||
|
"narHash": "sha256-J4j6LI+erWFp+maryBRN08Ra90BKLjv0NOhVC7subEY=",
|
||||||
|
"ref": "dev",
|
||||||
|
"rev": "d08a00a94acda0d1622de5960fa74750bf7d64db",
|
||||||
|
"revCount": 646,
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@methanium.net/tensamin/client"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"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": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1790572556,
|
||||||
|
"narHash": "sha256-ugNZR2Be9N9UjG0aVN8Yrk4hYOVC2edjtC9xBhbW35w=",
|
||||||
|
"ref": "main",
|
||||||
|
"rev": "4f18c7a0d9b04d38a77fbb011c4f0b21c25bf7bf",
|
||||||
|
"revCount": 28,
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"ref": "main",
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1790981744,
|
||||||
|
"narHash": "sha256-sm6DclXJudZfP/pcDBQQsRqyMxBcQsuw+7yQr4rBBLE=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "55ba7f49ef2962b42cbd126522b7df5f95037679",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"omega": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791114269,
|
||||||
|
"narHash": "sha256-56Nh5XnH7SK1P0kdtai/ZKcuQr8YlgDo5ndBf67YnFo=",
|
||||||
|
"ref": "main",
|
||||||
|
"rev": "993fa5ead0cfe5f5f0ab1ecd12ffe0f343380489",
|
||||||
|
"revCount": 157,
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@methanium.net/tensamin/omega"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"client": "client",
|
||||||
|
"iota": "iota",
|
||||||
|
"mtp": "mtp",
|
||||||
|
"mtp-type-maps": "mtp-type-maps",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"omega": "omega",
|
||||||
|
"omikron": "omikron",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791101754,
|
||||||
|
"narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
52
flake.nix
52
flake.nix
|
|
@ -1,13 +1,51 @@
|
||||||
{
|
{
|
||||||
|
description = "Shared production sources and builds for Tensamin";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
iota = { url = "git+ssh://git@methanium.net/tensamin/iota?ref=main"; flake = false; };
|
||||||
|
omikron = { url = "git+ssh://git@methanium.net/tensamin/omikron?ref=main"; flake = false; };
|
||||||
|
omega = { url = "git+ssh://git@methanium.net/tensamin/omega?ref=main"; flake = false; };
|
||||||
|
client = { url = "git+ssh://git@methanium.net/tensamin/client?ref=dev"; flake = false; };
|
||||||
|
mtp = { url = "git+ssh://git@methanium.net/methanium/mtp?ref=master"; flake = false; };
|
||||||
|
mtp-type-maps = { url = "git+ssh://git@methanium.net/tensamin/mtp-type-maps?ref=main"; flake = false; };
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = inputs: {
|
outputs = inputs@{ self, nixpkgs, ... }:
|
||||||
packages = builtins.mapAttrs (system: pkgs: {
|
let
|
||||||
hello = pkgs.hello;
|
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
|
forSystems = nixpkgs.lib.genAttrs systems;
|
||||||
default = inputs.self.packages.${system}.hello;
|
project = system: import ./packages {
|
||||||
}) inputs.nixpkgs.legacyPackages;
|
inherit inputs system;
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ inputs.rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
packages = forSystems (system: (project system).packages);
|
||||||
|
devShells = forSystems (system: (project system).devShells);
|
||||||
|
apps = forSystems (system: {
|
||||||
|
update-all = {
|
||||||
|
type = "app";
|
||||||
|
program = "${self.packages.${system}.update-all}/bin/update-all";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
lib.mkPackages = { system, sources ? {}, hashes ? import ./packages/hashes.nix }:
|
||||||
|
import ./packages {
|
||||||
|
inherit system hashes;
|
||||||
|
inputs = inputs // sources;
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ inputs.rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nixosModules = if builtins.pathExists ./modules/default.nix
|
||||||
|
then import ./modules/default.nix { inherit self; }
|
||||||
|
else {};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
lib/prepare-source.py
Normal file
133
lib/prepare-source.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Make every Rust consumer use the same local MTP and type maps."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
project, source, mtp, maps, iota, destination = sys.argv[1:]
|
||||||
|
root = pathlib.Path(destination)
|
||||||
|
shutil.copytree(source, root, symlinks=True)
|
||||||
|
root.chmod(root.stat().st_mode | 0o200)
|
||||||
|
|
||||||
|
if project != "mtp":
|
||||||
|
shutil.copytree(mtp, root / ".mtp", symlinks=True)
|
||||||
|
mtp_root = root if project == "mtp" else root / ".mtp"
|
||||||
|
crates = {}
|
||||||
|
for manifest in mtp_root.rglob("Cargo.toml"):
|
||||||
|
data = tomllib.loads(manifest.read_text())
|
||||||
|
if "name" in data.get("package", {}):
|
||||||
|
crates[data["package"]["name"]] = manifest.parent
|
||||||
|
|
||||||
|
if project == "omega":
|
||||||
|
shutil.copytree(pathlib.Path(iota) / "iota-identity", root / ".identity")
|
||||||
|
for path in [root, *root.rglob("*")]:
|
||||||
|
if not path.is_symlink():
|
||||||
|
path.chmod(path.stat().st_mode | 0o200)
|
||||||
|
if project == "omega":
|
||||||
|
manifest = root / "Cargo.toml"
|
||||||
|
manifest.write_text(manifest.read_text().replace('../iota/iota-identity', './.identity'))
|
||||||
|
|
||||||
|
for manifest in root.rglob("Cargo.toml"):
|
||||||
|
text = manifest.read_text()
|
||||||
|
# Preserve feature selections while replacing every independent git revision.
|
||||||
|
def local_dependency(match, manifest=manifest):
|
||||||
|
name, attributes = match.groups()
|
||||||
|
path = pathlib.Path(os.path.relpath(crates[name], manifest.parent))
|
||||||
|
attributes = re.sub(r'git\s*=\s*"[^"]+"\s*,?\s*', '', attributes)
|
||||||
|
attributes = re.sub(r'(rev|branch|tag)\s*=\s*"[^"]+"\s*,?\s*', '', attributes)
|
||||||
|
return f'{name} = {{ path = "{path}", {attributes}'
|
||||||
|
|
||||||
|
text = re.sub(r'(mtp(?:-[\w-]+)?)\s*=\s*\{([^}]*git\s*=\s*"[^"]*[Mm]ethanium/mtp[^}]*)(?=\})', local_dependency, text)
|
||||||
|
if manifest == root / "Cargo.toml" and project == "iota":
|
||||||
|
text = text.replace('exclude = [', 'exclude = [".mtp", ')
|
||||||
|
manifest.write_text(text)
|
||||||
|
|
||||||
|
maps_dir = root / "mtp-type-maps"
|
||||||
|
if maps_dir.is_symlink() or maps_dir.is_file():
|
||||||
|
maps_dir.unlink()
|
||||||
|
elif maps_dir.exists():
|
||||||
|
shutil.rmtree(maps_dir)
|
||||||
|
shutil.copytree(maps, maps_dir)
|
||||||
|
|
||||||
|
# Cargo reads only configuration from the invocation directory and its parents.
|
||||||
|
config = root / ".cargo" / "config.toml"
|
||||||
|
if config.exists():
|
||||||
|
config.unlink()
|
||||||
|
|
||||||
|
if project == "iota":
|
||||||
|
# Keep native executables usable before a system installation, without a
|
||||||
|
# shell launcher or store paths in the portable release.
|
||||||
|
paths = root / "iota-paths/src/lib.rs"
|
||||||
|
text = paths.read_text()
|
||||||
|
text = text.replace(
|
||||||
|
'return Err(PathError::MissingRequiredOverride("IOTA_SOCKET"));',
|
||||||
|
'''let home = absolute_env("HOME")?
|
||||||
|
.ok_or(PathError::MissingPlatformDirectory("home directory"))?;
|
||||||
|
let runtime = absolute_env("XDG_RUNTIME_DIR")?
|
||||||
|
.unwrap_or(xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?);
|
||||||
|
return Ok(IpcEndpoint::UnixSocket(runtime.join("iota/iota.sock")));''',
|
||||||
|
)
|
||||||
|
for program in ["daemon", "updater"]:
|
||||||
|
text = text.replace(
|
||||||
|
f'.unwrap_or_else(|| install_root().join("current/bin/iota-{program}"))',
|
||||||
|
f'''.unwrap_or_else(|| {{
|
||||||
|
let sibling = env::current_exe().ok()
|
||||||
|
.and_then(|path| path.parent().map(|parent| parent.join("iota-{program}")));
|
||||||
|
sibling.filter(|path| path.is_file())
|
||||||
|
.unwrap_or_else(|| install_root().join("current/bin/iota-{program}"))
|
||||||
|
}})''',
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
'.unwrap_or(defaults.asset_dir);',
|
||||||
|
'''.unwrap_or_else(|| {
|
||||||
|
env::current_exe().ok()
|
||||||
|
.and_then(|path| path.parent()?.parent().map(|parent| parent.join("share/iota/web")))
|
||||||
|
.filter(|path| path.is_dir())
|
||||||
|
.unwrap_or(defaults.asset_dir)
|
||||||
|
});''',
|
||||||
|
)
|
||||||
|
paths.write_text(text)
|
||||||
|
daemon = root / "iota-daemon/src/main.rs"
|
||||||
|
daemon.write_text(daemon.read_text().replace(
|
||||||
|
' if !iota_terms::consent::load(&paths.state_dir).has_all_required() {',
|
||||||
|
''' if let Err(error) = paths.prepare_writable_directories() {
|
||||||
|
eprintln!("Cannot prepare Iota directories: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
if !iota_terms::consent::load(&paths.state_dir).has_all_required() {''',
|
||||||
|
1,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Ship and install every pinned static asset through the existing ZIP
|
||||||
|
# contract, so the validator and installer agree about the payload.
|
||||||
|
assets = sorted(path.relative_to(root).as_posix() for path in (root / "static/web").rglob("*") if path.is_file())
|
||||||
|
contract = root / "iota-installer/bundle-files.txt"
|
||||||
|
contract.write_text(contract.read_text().rstrip() + "\n" + "\n".join(assets) + "\n")
|
||||||
|
installer = root / "iota-installer/src/lib.rs"
|
||||||
|
text = installer.read_text().replace(
|
||||||
|
' for unit in [',
|
||||||
|
''' for name in REQUIRED.lines().filter(|name| name.starts_with("static/web/")) {
|
||||||
|
install(
|
||||||
|
&staging.path().join(name),
|
||||||
|
&format!("/usr/local/share/iota/web/{}", &name["static/web/".len()..]),
|
||||||
|
"0644",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for unit in [''',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
installer.write_text(text)
|
||||||
|
|
||||||
|
# Drop git identities for the crates now supplied through local paths. Cargo
|
||||||
|
# updates the dependency graph during update-all, never inside a sandboxed build.
|
||||||
|
lock = root / "Cargo.lock"
|
||||||
|
if lock.exists():
|
||||||
|
text = lock.read_text()
|
||||||
|
text = re.sub(r'(\[\[package\]\]\nname = "mtp[^\n]*\n.*?)(?=\n\[\[package\]\]|\Z)',
|
||||||
|
lambda m: re.sub(r'^source = .*\n|^checksum = .*\n', '', m[0], flags=re.MULTILINE), text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r'(mtp[\w-]* [\d.]+) \(git\+[^)]+\)', r'\1', text)
|
||||||
|
lock.write_text(text)
|
||||||
106
modules/README.md
Normal file
106
modules/README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# NixOS modules
|
||||||
|
|
||||||
|
`import ./default.nix { inherit self; }` returns `default`, `iota`, `omikron`,
|
||||||
|
`omega`, and `client`. `default` imports all four component modules. The flake's
|
||||||
|
existing conditional import accepts this interface directly.
|
||||||
|
|
||||||
|
These new files must be included in the consuming Git checkout for Git-backed
|
||||||
|
flake evaluation. Verification used `path:` to include the untracked modules.
|
||||||
|
|
||||||
|
All options live under `tensamin`, with no `services.tensamin` wrapper or legacy
|
||||||
|
`services.iota`, `services.omikron`, or `services.omega` aliases.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
All components have `enable`, `package`, `bindAddress`, `port`, and
|
||||||
|
`openFirewall`. Services are disabled by default. Package defaults resolve via
|
||||||
|
`self.packages.${pkgs.stdenv.hostPlatform.system}`.
|
||||||
|
|
||||||
|
| Component | Package | Bind address | Port | Open firewall |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `tensamin.iota` | `iota-daemon` | `0.0.0.0` | 1984 | true |
|
||||||
|
| `tensamin.omikron` | `omikron` | `0.0.0.0` | 443 | true |
|
||||||
|
| `tensamin.omega` | `omega` | `0.0.0.0` | 443 | true |
|
||||||
|
| `tensamin.client` | `client-web` | `127.0.0.1` | 8080 | false |
|
||||||
|
|
||||||
|
Iota, Omikron, and Omega open TCP and UDP. Client opens only TCP when requested.
|
||||||
|
|
||||||
|
### Iota
|
||||||
|
|
||||||
|
- `stateDir`, `cacheDir`, `runtimeDir`, `logDir` default to `/var/lib/iota`,
|
||||||
|
`/var/cache/iota`, `/run/iota`, `/var/log/iota`.
|
||||||
|
- `assetDir` defaults to the central `iota` package's pinned source
|
||||||
|
`static/web` directory. This is upstream's shipped asset directory, currently
|
||||||
|
containing its 404 page, not the client application. Override it to serve
|
||||||
|
other assets.
|
||||||
|
- `webMode` is `network` by default, or `loopback` or `disabled`. Upstream
|
||||||
|
requires TLS for both enabled modes. Set `bindAddress` explicitly for loopback.
|
||||||
|
- `certFile` and `keyFile` are nullable runtime path strings, supplied together.
|
||||||
|
An enabled listener requires them unless `settingsFile` supplies complete TLS
|
||||||
|
configuration.
|
||||||
|
- `omegaApiUrl` defaults to `https://omega.tensamin.net`.
|
||||||
|
- `environmentFiles` is a list of runtime path strings, defaulting to `[]`.
|
||||||
|
- `settings` contains YAML-compatible operator configuration, defaulting to `{}`.
|
||||||
|
Module listener and asset options take precedence. Startup rewrites the mutable
|
||||||
|
`stateDir/config.yaml`, preserving omitted `iota_id`, `omikron_host`,
|
||||||
|
`omikron_port`, and `omikron_id`. Explicit values, including null, override them.
|
||||||
|
Other daemon/operator edits to this file are replaced at the next startup.
|
||||||
|
- `settingsFile` is a nullable runtime path string. It replaces generated
|
||||||
|
settings, with the same preservation of omitted discovery fields. Its listener
|
||||||
|
and TLS settings must agree with the module's firewall and capability options.
|
||||||
|
Relative paths resolve against `stateDir`, not the source file's directory.
|
||||||
|
|
||||||
|
The systemd service and socket are named `iota`. IPC uses
|
||||||
|
`${runtimeDir}/iota.sock`, mode `0660`, owned by `iota:iota`. Add authorized
|
||||||
|
operators to the `iota` group. Daemon identities remain under
|
||||||
|
`${stateDir}/identity`; the module does not reseed them. Exit code 75 forces a
|
||||||
|
restart. Config paths, deployment mode, supervisor, and all mutable directories
|
||||||
|
are passed through the upstream `IOTA_*` environment contract.
|
||||||
|
|
||||||
|
### Omikron and Omega
|
||||||
|
|
||||||
|
- `stateDir` defaults to `/var/lib/omikron` or `/var/lib/omega` and is the working
|
||||||
|
directory of the matching systemd service and service user.
|
||||||
|
- Set either `acmeCertDir`, containing `fullchain.pem` and `key.pem`, or both
|
||||||
|
`certFile` and `keyFile`. All are nullable runtime path strings. Startup copies
|
||||||
|
the certificate to `certs/cert.pem` and converts the key to unencrypted PKCS8
|
||||||
|
at `certs/key.pem`, owned by the service user with mode `0600`.
|
||||||
|
- `identityFile` and `publicIdentityFile` are nullable runtime path strings,
|
||||||
|
supplied together. Startup copies them to `omikron.mk` and `omikron.mpkb`, or
|
||||||
|
`omega.mk` and `omega.mpkb`. Null retains existing state or lets upstream
|
||||||
|
generate an identity. Supplied identities are reapplied on every startup.
|
||||||
|
- `environment` is an attribute set of non-secret string settings, default `{}`.
|
||||||
|
Module-generated listener and Omikron discovery variables take precedence.
|
||||||
|
- `environmentFiles` is a list of runtime environment paths, default `[]`.
|
||||||
|
Systemd loads these after the declared environment, so they can override it.
|
||||||
|
Keep listener variables consistent with firewall options. Omega requires
|
||||||
|
`DB_URL`; it uses file-based identities, not `PRIVATE_KEY`/`PUBLIC_KEY`.
|
||||||
|
|
||||||
|
Omikron also requires positive `id` and `omegaTrustFile`, the runtime Omega public
|
||||||
|
key bundle copied to `omega.mpkb`. `omegaHost` defaults to `tensamin.net` and
|
||||||
|
`omegaPort` to the upstream default 9187. Set it to the deployed Omega listener
|
||||||
|
port, whose module default is 443. These become `ID`, `OMEGA_HOST`, `OMEGA_PORT`,
|
||||||
|
`RHO_PORT`, and `BIND_ADDRESS`. Omega uses `PORT` and `BIND_ADDRESS`.
|
||||||
|
|
||||||
|
Trust and identity installation runs for both manual TLS and ACME. Configure
|
||||||
|
certificate issuance separately and restart the corresponding service after
|
||||||
|
renewal so it recopies certificates. Environment and secret path strings do not
|
||||||
|
copy secret contents into the Nix store.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
`hostName` defaults to `localhost`. The module enables nginx and adds that
|
||||||
|
virtual host with an explicit HTTP listener at `bindAddress:port`, using
|
||||||
|
`client-web`'s output root and SPA fallback to `/index.html`. Configure public
|
||||||
|
TLS/proxy routing separately. It does not configure Anubis, guest accounts, or
|
||||||
|
install the Electron client.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The combined module was evaluated with actual central package outputs in
|
||||||
|
minimal x86_64-linux and aarch64-linux NixOS container configurations. All
|
||||||
|
assertions passed and `system.build.toplevel.drvPath` evaluated. Checks included
|
||||||
|
both server TLS branches, all services disabled, Iota disabled-listener mode,
|
||||||
|
a custom Iota runtime directory, and a custom nginx listener. Missing Omikron
|
||||||
|
TLS produces the intended assertion. Evaluation does not build or start the
|
||||||
|
applications.
|
||||||
51
modules/client.nix
Normal file
51
modules/client.nix
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{ self }:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.tensamin.client;
|
||||||
|
inherit (lib) mkOption types;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.tensamin.client = {
|
||||||
|
enable = lib.mkEnableOption "the static Tensamin web client";
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.client-web;
|
||||||
|
description = "Static client package with index.html at its output root.";
|
||||||
|
};
|
||||||
|
hostName = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "localhost";
|
||||||
|
};
|
||||||
|
bindAddress = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 8080;
|
||||||
|
};
|
||||||
|
openFirewall = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
services.nginx.enable = true;
|
||||||
|
services.nginx.virtualHosts.${cfg.hostName} = {
|
||||||
|
listen = [
|
||||||
|
{
|
||||||
|
addr = cfg.bindAddress;
|
||||||
|
inherit (cfg) port;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
root = cfg.package;
|
||||||
|
locations."/".tryFiles = "$uri $uri/ /index.html";
|
||||||
|
};
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
21
modules/default.nix
Normal file
21
modules/default.nix
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{ self }:
|
||||||
|
let
|
||||||
|
components = {
|
||||||
|
iota = import ./iota.nix { inherit self; };
|
||||||
|
omikron = import ./server.nix {
|
||||||
|
inherit self;
|
||||||
|
name = "omikron";
|
||||||
|
};
|
||||||
|
omega = import ./server.nix {
|
||||||
|
inherit self;
|
||||||
|
name = "omega";
|
||||||
|
};
|
||||||
|
client = import ./client.nix { inherit self; };
|
||||||
|
};
|
||||||
|
in
|
||||||
|
components
|
||||||
|
// {
|
||||||
|
default = {
|
||||||
|
imports = builtins.attrValues components;
|
||||||
|
};
|
||||||
|
}
|
||||||
251
modules/iota.nix
Normal file
251
modules/iota.nix
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
{ self }:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.tensamin.iota;
|
||||||
|
inherit (lib) mkOption types;
|
||||||
|
format = pkgs.formats.yaml { };
|
||||||
|
settings = lib.recursiveUpdate cfg.settings {
|
||||||
|
port = cfg.port;
|
||||||
|
web = {
|
||||||
|
mode = cfg.webMode;
|
||||||
|
bind = cfg.bindAddress;
|
||||||
|
port = cfg.port;
|
||||||
|
required = cfg.webMode != "disabled";
|
||||||
|
asset_dir = cfg.assetDir;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.certFile != null) {
|
||||||
|
certificate = "${cfg.stateDir}/tls/cert.pem";
|
||||||
|
key = "${cfg.stateDir}/tls/key.pem";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
sourceConfig =
|
||||||
|
if cfg.settingsFile != null then cfg.settingsFile else format.generate "iota-config.yaml" settings;
|
||||||
|
configFile = "${cfg.stateDir}/config.yaml";
|
||||||
|
python = pkgs.python3.withPackages (ps: [ ps.pyyaml ]);
|
||||||
|
mergeConfig = pkgs.writeText "iota-merge-config.py" ''
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
source, destination = sys.argv[1:]
|
||||||
|
with open(source) as stream:
|
||||||
|
settings = yaml.safe_load(stream) or {}
|
||||||
|
if os.path.exists(destination):
|
||||||
|
with open(destination) as stream:
|
||||||
|
previous = yaml.safe_load(stream) or {}
|
||||||
|
# Retain discovery state unless the operator explicitly supplies it.
|
||||||
|
for field in ["iota_id", "omikron_host", "omikron_port", "omikron_id"]:
|
||||||
|
if field not in settings and field in previous:
|
||||||
|
settings[field] = previous[field]
|
||||||
|
temporary = destination + ".new"
|
||||||
|
with open(temporary, "w") as stream:
|
||||||
|
yaml.safe_dump(settings, stream, sort_keys=False)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
'';
|
||||||
|
setup = pkgs.writeShellScript "iota-setup" ''
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
${python}/bin/python ${mergeConfig} ${lib.escapeShellArg sourceConfig} ${lib.escapeShellArg configFile}
|
||||||
|
chown iota:iota ${lib.escapeShellArg configFile}
|
||||||
|
${lib.optionalString (cfg.certFile != null) ''
|
||||||
|
install -d -m 0700 -o iota -g iota ${lib.escapeShellArg "${cfg.stateDir}/tls"}
|
||||||
|
install -m 0644 -o iota -g iota ${lib.escapeShellArg cfg.certFile} ${lib.escapeShellArg "${cfg.stateDir}/tls/cert.pem"}
|
||||||
|
install -m 0600 -o iota -g iota ${lib.escapeShellArg cfg.keyFile} ${lib.escapeShellArg "${cfg.stateDir}/tls/key.pem"}
|
||||||
|
''}
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.tensamin.iota = {
|
||||||
|
enable = lib.mkEnableOption "the Tensamin Iota daemon";
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon;
|
||||||
|
};
|
||||||
|
stateDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/lib/iota";
|
||||||
|
};
|
||||||
|
cacheDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/cache/iota";
|
||||||
|
};
|
||||||
|
runtimeDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/run/iota";
|
||||||
|
};
|
||||||
|
logDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/log/iota";
|
||||||
|
};
|
||||||
|
assetDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "${self.packages.${pkgs.stdenv.hostPlatform.system}.iota.passthru.source}/static/web";
|
||||||
|
description = "Static Iota assets. Defaults to the pinned source's shipped web directory.";
|
||||||
|
};
|
||||||
|
bindAddress = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "0.0.0.0";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 1984;
|
||||||
|
};
|
||||||
|
webMode = mkOption {
|
||||||
|
type = types.enum [
|
||||||
|
"disabled"
|
||||||
|
"loopback"
|
||||||
|
"network"
|
||||||
|
];
|
||||||
|
default = "network";
|
||||||
|
description = "Iota listener mode. Both loopback and network modes require TLS upstream.";
|
||||||
|
};
|
||||||
|
certFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Runtime TLS certificate path.";
|
||||||
|
};
|
||||||
|
keyFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Runtime TLS private key path.";
|
||||||
|
};
|
||||||
|
omegaApiUrl = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "https://omega.tensamin.net";
|
||||||
|
};
|
||||||
|
openFirewall = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
};
|
||||||
|
environmentFiles = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
};
|
||||||
|
settings = mkOption {
|
||||||
|
type = format.type;
|
||||||
|
default = { };
|
||||||
|
description = "Operator settings, refreshed at startup. Listener options take precedence; omitted discovery fields retain daemon values.";
|
||||||
|
};
|
||||||
|
settingsFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Complete runtime YAML configuration, replacing generated settings. Its listener and TLS settings must agree with module options.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = (cfg.certFile == null) == (cfg.keyFile == null);
|
||||||
|
message = "tensamin.iota: certFile and keyFile must be supplied together.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = cfg.settingsFile != null || cfg.webMode == "disabled" || cfg.certFile != null;
|
||||||
|
message = "tensamin.iota: an enabled listener requires certFile and keyFile, or a complete settingsFile with TLS.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all (path: lib.hasPrefix "/" path) [
|
||||||
|
cfg.stateDir
|
||||||
|
cfg.cacheDir
|
||||||
|
cfg.runtimeDir
|
||||||
|
cfg.logDir
|
||||||
|
cfg.assetDir
|
||||||
|
];
|
||||||
|
message = "tensamin.iota: directory paths must be absolute.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
users.users.iota = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "iota";
|
||||||
|
home = cfg.stateDir;
|
||||||
|
};
|
||||||
|
users.groups.iota = { };
|
||||||
|
systemd.tmpfiles.rules = map (path: "d ${path} 0750 iota iota -") [
|
||||||
|
cfg.stateDir
|
||||||
|
cfg.cacheDir
|
||||||
|
cfg.runtimeDir
|
||||||
|
cfg.logDir
|
||||||
|
];
|
||||||
|
systemd.sockets.iota = {
|
||||||
|
description = "Tensamin Iota IPC socket";
|
||||||
|
wantedBy = [ "sockets.target" ];
|
||||||
|
socketConfig = {
|
||||||
|
ListenStream = "${cfg.runtimeDir}/iota.sock";
|
||||||
|
SocketMode = "0660";
|
||||||
|
SocketUser = "iota";
|
||||||
|
SocketGroup = "iota";
|
||||||
|
DirectoryMode = "0750";
|
||||||
|
Backlog = 5;
|
||||||
|
RemoveOnStop = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
systemd.services.iota = {
|
||||||
|
description = "Tensamin Iota daemon";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [
|
||||||
|
"network.target"
|
||||||
|
"iota.socket"
|
||||||
|
];
|
||||||
|
requires = [ "iota.socket" ];
|
||||||
|
environment = {
|
||||||
|
OMEGA_API_URL = cfg.omegaApiUrl;
|
||||||
|
IOTA_SOCKET = "${cfg.runtimeDir}/iota.sock";
|
||||||
|
IOTA_CONFIG_FILE = configFile;
|
||||||
|
IOTA_CONFIG_DIR = cfg.stateDir;
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "iota";
|
||||||
|
Group = "iota";
|
||||||
|
WorkingDirectory = cfg.stateDir;
|
||||||
|
ExecStart = "${cfg.package}/bin/iota-daemon";
|
||||||
|
ExecStartPre = [ "+${setup}" ];
|
||||||
|
EnvironmentFile = cfg.environmentFiles;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
RestartPreventExitStatus = "0";
|
||||||
|
RestartForceExitStatus = "75";
|
||||||
|
TimeoutStopSec = "10s";
|
||||||
|
KillMode = "mixed";
|
||||||
|
KillSignal = "SIGTERM";
|
||||||
|
UMask = "0077";
|
||||||
|
AmbientCapabilities = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
|
||||||
|
CapabilityBoundingSet = lib.optional (cfg.port < 1024) "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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.firewall = lib.mkIf (cfg.openFirewall && cfg.webMode != "disabled") {
|
||||||
|
allowedTCPPorts = [ cfg.port ];
|
||||||
|
allowedUDPPorts = [ cfg.port ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
199
modules/server.nix
Normal file
199
modules/server.nix
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
{ self, name }:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.tensamin.${name};
|
||||||
|
isOmikron = name == "omikron";
|
||||||
|
inherit (lib) mkOption types;
|
||||||
|
cert = if cfg.acmeCertDir != null then "${cfg.acmeCertDir}/fullchain.pem" else cfg.certFile;
|
||||||
|
key = if cfg.acmeCertDir != null then "${cfg.acmeCertDir}/key.pem" else cfg.keyFile;
|
||||||
|
setup = pkgs.writeShellScript "${name}-setup" ''
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
install -d -m 0750 -o ${name} -g ${name} ${lib.escapeShellArg cfg.stateDir}
|
||||||
|
cd ${lib.escapeShellArg cfg.stateDir}
|
||||||
|
install -d -m 0700 -o ${name} -g ${name} certs
|
||||||
|
install -m 0644 -o ${name} -g ${name} ${
|
||||||
|
lib.escapeShellArg (if cert == null then "" else cert)
|
||||||
|
} certs/cert.pem
|
||||||
|
${pkgs.openssl}/bin/openssl pkcs8 -topk8 -nocrypt \
|
||||||
|
-in ${lib.escapeShellArg (if key == null then "" else key)} -out certs/key.pem
|
||||||
|
chown ${name}:${name} certs/key.pem
|
||||||
|
chmod 0600 certs/key.pem
|
||||||
|
${lib.optionalString isOmikron ''
|
||||||
|
install -m 0644 -o ${name} -g ${name} ${lib.escapeShellArg cfg.omegaTrustFile} omega.mpkb
|
||||||
|
''}
|
||||||
|
${lib.optionalString (cfg.identityFile != null) ''
|
||||||
|
install -m 0600 -o ${name} -g ${name} ${lib.escapeShellArg cfg.identityFile} ${name}.mk
|
||||||
|
''}
|
||||||
|
${lib.optionalString (cfg.publicIdentityFile != null) ''
|
||||||
|
install -m 0644 -o ${name} -g ${name} ${lib.escapeShellArg cfg.publicIdentityFile} ${name}.mpkb
|
||||||
|
''}
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.tensamin.${name} = {
|
||||||
|
enable = lib.mkEnableOption "Tensamin ${name}";
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.${name};
|
||||||
|
description = "Central ${name} package, or an explicit replacement.";
|
||||||
|
};
|
||||||
|
stateDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/lib/${name}";
|
||||||
|
description = "Persistent working directory, including identities and certificates.";
|
||||||
|
};
|
||||||
|
bindAddress = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "0.0.0.0";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 443;
|
||||||
|
};
|
||||||
|
openFirewall = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
};
|
||||||
|
certFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Runtime TLS certificate path. Set together with keyFile.";
|
||||||
|
};
|
||||||
|
keyFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Runtime TLS private key path. Never copied into the Nix store.";
|
||||||
|
};
|
||||||
|
acmeCertDir = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Runtime directory containing fullchain.pem and key.pem. Restart the service after renewal.";
|
||||||
|
};
|
||||||
|
identityFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Existing private keyring to install at startup, or null to retain or generate state.";
|
||||||
|
};
|
||||||
|
publicIdentityFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Matching public key bundle to install at startup.";
|
||||||
|
};
|
||||||
|
environment = mkOption {
|
||||||
|
type = types.attrsOf types.str;
|
||||||
|
default = { };
|
||||||
|
description = "Additional non-secret runtime settings. Listener options take precedence.";
|
||||||
|
};
|
||||||
|
environmentFiles = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
description =
|
||||||
|
if isOmikron then
|
||||||
|
"Runtime environment files, including optional LiveKit credentials."
|
||||||
|
else
|
||||||
|
"Runtime environment files. Supply DB_URL here; identities are file-based.";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs isOmikron {
|
||||||
|
id = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
description = "Omikron ID assigned by Omega.";
|
||||||
|
};
|
||||||
|
omegaHost = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "tensamin.net";
|
||||||
|
};
|
||||||
|
omegaPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 9187;
|
||||||
|
};
|
||||||
|
omegaTrustFile = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Runtime path to Omega's trusted public key bundle.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
(cfg.acmeCertDir != null && cfg.certFile == null && cfg.keyFile == null)
|
||||||
|
|| (cfg.acmeCertDir == null && cfg.certFile != null && cfg.keyFile != null);
|
||||||
|
message = "tensamin.${name}: set either acmeCertDir or both certFile and keyFile.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = (cfg.identityFile == null) == (cfg.publicIdentityFile == null);
|
||||||
|
message = "tensamin.${name}: identityFile and publicIdentityFile must be supplied together.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.hasPrefix "/" cfg.stateDir;
|
||||||
|
message = "tensamin.${name}.stateDir must be absolute.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
users.users.${name} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = name;
|
||||||
|
home = cfg.stateDir;
|
||||||
|
};
|
||||||
|
users.groups.${name} = { };
|
||||||
|
systemd.tmpfiles.rules = [ "d ${cfg.stateDir} 0750 ${name} ${name} -" ];
|
||||||
|
systemd.services.${name} = {
|
||||||
|
description = "Tensamin ${name}";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
environment =
|
||||||
|
cfg.environment
|
||||||
|
// {
|
||||||
|
BIND_ADDRESS = cfg.bindAddress;
|
||||||
|
}
|
||||||
|
// (
|
||||||
|
if isOmikron then
|
||||||
|
{
|
||||||
|
RHO_PORT = toString cfg.port;
|
||||||
|
OMEGA_HOST = cfg.omegaHost;
|
||||||
|
OMEGA_PORT = toString cfg.omegaPort;
|
||||||
|
ID = toString cfg.id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ PORT = toString cfg.port; }
|
||||||
|
);
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = name;
|
||||||
|
Group = name;
|
||||||
|
WorkingDirectory = cfg.stateDir;
|
||||||
|
ExecStart = "${cfg.package}/bin/${name}";
|
||||||
|
ExecStartPre = [ "+${setup}" ];
|
||||||
|
EnvironmentFile = cfg.environmentFiles;
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = "5s";
|
||||||
|
UMask = "0077";
|
||||||
|
AmbientCapabilities = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
|
||||||
|
CapabilityBoundingSet = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ReadWritePaths = [ cfg.stateDir ];
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
LockPersonality = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||||
|
allowedTCPPorts = [ cfg.port ];
|
||||||
|
allowedUDPPorts = [ cfg.port ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
137
packages/client.nix
Normal file
137
packages/client.nix
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
{ pkgs, rust, rustPlatform, inputs, hashes, mtpSource, mtpVendor }:
|
||||||
|
let
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
pnpm = pkgs.pnpm;
|
||||||
|
sdk-deps = pkgs.fetchPnpmDeps {
|
||||||
|
pname = "mtp-sdk";
|
||||||
|
version = "0.4.0";
|
||||||
|
src = inputs.mtp;
|
||||||
|
inherit pnpm;
|
||||||
|
fetcherVersion = 4;
|
||||||
|
hash = hashes.sdk;
|
||||||
|
};
|
||||||
|
wasmVersion = (lib.findFirst (p: p.name == "wasm-bindgen") (throw "Missing wasm-bindgen")
|
||||||
|
(builtins.fromTOML (builtins.readFile (inputs.mtp + "/Cargo.lock"))).package).version;
|
||||||
|
wasmBindgen = pkgs.${"wasm-bindgen-cli_" + builtins.replaceStrings [ "." ] [ "_" ] wasmVersion};
|
||||||
|
mtp-sdk = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "mtp-sdk";
|
||||||
|
version = "0.4.0";
|
||||||
|
src = mtpSource;
|
||||||
|
pnpmDeps = sdk-deps;
|
||||||
|
cargoDeps = mtpVendor;
|
||||||
|
nativeBuildInputs = [ rust pkgs.nodejs pnpm pkgs.pnpmConfigHook rustPlatform.cargoSetupHook wasmBindgen pkgs.lld ];
|
||||||
|
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
|
||||||
|
RUSTFLAGS = "--cfg=web_sys_unstable_apis";
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
cargo build --locked --offline --release -p mtp-wasm --target wasm32-unknown-unknown
|
||||||
|
mkdir -p sdk/bindings/wasm/pkg
|
||||||
|
wasm-bindgen target/wasm32-unknown-unknown/release/mtp_wasm.wasm \
|
||||||
|
--target web --out-dir sdk/bindings/wasm/pkg --out-name mtp_wasm
|
||||||
|
pnpm --dir sdk run build:ts
|
||||||
|
# The Nix SDK already contains WASM compiled with the authoritative maps.
|
||||||
|
# Keep the plugin's YAML generation and aliases, but reuse that build.
|
||||||
|
substituteInPlace sdk/dist/vite/index.js \
|
||||||
|
--replace-fail 'await runWasmPack(state);' \
|
||||||
|
'await fs.cp(path.join(packageRoot, "bindings/wasm/pkg"), state.outDir, { recursive: true });'
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp -r sdk/dist sdk/bindings sdk/type-map sdk/package.json "$out/"
|
||||||
|
mkdir -p "$out/node_modules"
|
||||||
|
cp -rL sdk/node_modules/yaml "$out/node_modules/"
|
||||||
|
'';
|
||||||
|
passthru.source = inputs.mtp;
|
||||||
|
};
|
||||||
|
client-source = pkgs.runCommand "client-source" { nativeBuildInputs = [ pkgs.python3 ]; } ''
|
||||||
|
cp -r ${inputs.client} "$out"
|
||||||
|
chmod -R u+w "$out"
|
||||||
|
rm -rf "$out/mtp-type-maps"
|
||||||
|
cp -r ${inputs.mtp-type-maps} "$out/mtp-type-maps"
|
||||||
|
mkdir -p "$out/.mtp-sdk"
|
||||||
|
cp ${inputs.mtp}/sdk/package.json "$out/.mtp-sdk/package.json"
|
||||||
|
python - "$out" <<'PY'
|
||||||
|
import json, sys, os, re
|
||||||
|
from pathlib import Path
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
workspace = root / 'pnpm-workspace.yaml'
|
||||||
|
workspace.write_text(re.sub(r'^ mtp:.*\n', "", workspace.read_text(), flags=re.M))
|
||||||
|
for path in root.rglob('package.json'):
|
||||||
|
if '.mtp-sdk' in path.parts:
|
||||||
|
continue
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for section in ['dependencies', 'devDependencies']:
|
||||||
|
if 'mtp' in data.get(section, {}):
|
||||||
|
data[section]['mtp'] = 'link:' + os.path.relpath(root / '.mtp-sdk', path.parent)
|
||||||
|
path.write_text(json.dumps(data, indent=2) + '\n')
|
||||||
|
PY
|
||||||
|
${lib.optionalString (builtins.pathExists ./locks/client.yaml) ''cp ${./locks/client.yaml} "$out/pnpm-lock.yaml"''}
|
||||||
|
'';
|
||||||
|
client-deps = pkgs.fetchPnpmDeps {
|
||||||
|
pname = "tensamin";
|
||||||
|
version = "0.0.11";
|
||||||
|
src = client-source;
|
||||||
|
inherit pnpm;
|
||||||
|
fetcherVersion = 4;
|
||||||
|
hash = hashes.client;
|
||||||
|
};
|
||||||
|
common = {
|
||||||
|
version = "0.0.11";
|
||||||
|
src = client-source;
|
||||||
|
pnpmDeps = client-deps;
|
||||||
|
nativeBuildInputs = [ pkgs.nodejs pnpm pkgs.pnpmConfigHook pkgs.makeWrapper ];
|
||||||
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
|
postPatch = ''
|
||||||
|
rm -rf .mtp-sdk
|
||||||
|
cp -r ${mtp-sdk} .mtp-sdk
|
||||||
|
chmod -R u+w .mtp-sdk
|
||||||
|
'';
|
||||||
|
passthru = { source = inputs.client; inherit mtp-sdk; };
|
||||||
|
meta.platforms = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
|
};
|
||||||
|
client-web = pkgs.stdenv.mkDerivation (common // {
|
||||||
|
pname = "tensamin-web";
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
pnpm run build:web
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''mkdir -p "$out"; cp -r apps/web/dist/. "$out/"'';
|
||||||
|
});
|
||||||
|
client = pkgs.stdenv.mkDerivation (common // {
|
||||||
|
pname = "tensamin";
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
TENSAMIN_WEB_BASE=./ pnpm run build:web
|
||||||
|
pnpm --dir apps/electron run build
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p "$out/lib/tensamin" "$out/bin"
|
||||||
|
cp -r apps/electron/dist "$out/lib/tensamin/"
|
||||||
|
cp apps/electron/package.json "$out/lib/tensamin/"
|
||||||
|
mkdir -p "$out/lib/web/dist"
|
||||||
|
cp -r apps/web/dist/. "$out/lib/web/dist/"
|
||||||
|
makeWrapper ${pkgs.electron}/bin/electron "$out/bin/tensamin" \
|
||||||
|
--add-flags "$out/lib/tensamin" \
|
||||||
|
--prefix PATH : ${lib.makeBinPath [ pkgs.pulseaudio ]} \
|
||||||
|
--set ELECTRON_OZONE_PLATFORM_HINT auto
|
||||||
|
if [ -d apps/electron/build/icons ]; then
|
||||||
|
mkdir -p "$out/lib/tensamin/build"
|
||||||
|
cp -r apps/electron/build/icons "$out/lib/tensamin/build/icons"
|
||||||
|
install -Dm644 apps/electron/build/icons/icon.png "$out/share/icons/hicolor/512x512/apps/tensamin.png"
|
||||||
|
fi
|
||||||
|
mkdir -p "$out/share/applications"
|
||||||
|
cp ${pkgs.makeDesktopItem {
|
||||||
|
name = "tensamin";
|
||||||
|
desktopName = "Tensamin";
|
||||||
|
exec = "tensamin %U";
|
||||||
|
icon = "tensamin";
|
||||||
|
categories = [ "Network" ];
|
||||||
|
mimeTypes = [ "x-scheme-handler/tensamin" ];
|
||||||
|
}}/share/applications/* "$out/share/applications/"
|
||||||
|
'';
|
||||||
|
meta = common.meta // { mainProgram = "tensamin"; };
|
||||||
|
});
|
||||||
|
in { inherit client client-web mtp-sdk client-source client-deps sdk-deps; }
|
||||||
196
packages/default.nix
Normal file
196
packages/default.nix
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
{ inputs, pkgs, system, hashes ? import ./hashes.nix }:
|
||||||
|
let
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||||
|
targets = [ "wasm32-unknown-unknown" "${pkgs.stdenv.hostPlatform.parsed.cpu.name}-unknown-linux-musl" ];
|
||||||
|
extensions = [ "rust-src" "rustfmt" "clippy" ];
|
||||||
|
};
|
||||||
|
rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
|
||||||
|
projects = [ "iota" "omikron" "omega" "mtp" ];
|
||||||
|
prepared = lib.genAttrs projects (name: pkgs.runCommand "${name}-source" {
|
||||||
|
nativeBuildInputs = [ pkgs.python3 ] ++ lib.optional (name == "iota") rust;
|
||||||
|
} ''
|
||||||
|
python ${../lib/prepare-source.py} ${name} ${inputs.${name}} ${inputs.mtp} \
|
||||||
|
${inputs.mtp-type-maps} ${inputs.iota} "$out"
|
||||||
|
${lib.optionalString (name == "iota") ''
|
||||||
|
rustfmt --edition 2024 "$out/iota-paths/src/lib.rs" "$out/iota-installer/src/lib.rs" "$out/iota-daemon/src/main.rs"
|
||||||
|
''}
|
||||||
|
'');
|
||||||
|
sources = lib.genAttrs projects (name:
|
||||||
|
let lock = ./locks + "/${name}.lock";
|
||||||
|
in if builtins.pathExists lock then pkgs.runCommand "${name}-locked-source" {} ''
|
||||||
|
cp -r ${prepared.${name}} "$out"
|
||||||
|
chmod -R u+w "$out"
|
||||||
|
cp ${lock} "$out/Cargo.lock"
|
||||||
|
'' else prepared.${name});
|
||||||
|
vendors = lib.genAttrs projects (name: rustPlatform.fetchCargoVendor {
|
||||||
|
pname = "${name}-vendor";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = sources.${name};
|
||||||
|
hash = hashes.${name};
|
||||||
|
});
|
||||||
|
mkRust = name: flags: rustPlatform.buildRustPackage {
|
||||||
|
pname = name;
|
||||||
|
version = "0.1.0";
|
||||||
|
src = sources.${name};
|
||||||
|
cargoDeps = vendors.${name};
|
||||||
|
cargoBuildFlags = flags;
|
||||||
|
nativeBuildInputs = [ pkgs.cmake pkgs.perl pkgs.pkg-config ];
|
||||||
|
buildInputs = [ pkgs.openssl pkgs.sqlite ] ++ lib.optional (name == "omega") pkgs.libmysqlclient;
|
||||||
|
dontUseCmakeConfigure = true;
|
||||||
|
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
|
||||||
|
doCheck = false;
|
||||||
|
postInstall = lib.optionalString (name == "iota") ''
|
||||||
|
mkdir -p "$out/share/iota"
|
||||||
|
cp -r static/web "$out/share/iota/web"
|
||||||
|
'';
|
||||||
|
passthru = {
|
||||||
|
source = inputs.${name};
|
||||||
|
transformedSource = sources.${name};
|
||||||
|
inherit (inputs) mtp mtp-type-maps;
|
||||||
|
};
|
||||||
|
meta = { platforms = [ "x86_64-linux" "aarch64-linux" ]; mainProgram = name; };
|
||||||
|
};
|
||||||
|
iota = mkRust "iota" [ "-p" "iota" "-p" "iota-daemon" "-p" "iota-updater" "-p" "iota-installer" ];
|
||||||
|
staticPkgs = pkgs.pkgsStatic;
|
||||||
|
iotaPortable = rustPlatform.buildRustPackage {
|
||||||
|
pname = "iota-portable";
|
||||||
|
inherit (iota) version src cargoDeps cargoBuildFlags postInstall;
|
||||||
|
nativeBuildInputs = [ pkgs.cmake pkgs.perl pkgs.pkg-config pkgs.binutils pkgs.removeReferencesTo staticPkgs.stdenv.cc ];
|
||||||
|
buildInputs = [ staticPkgs.openssl staticPkgs.sqlite staticPkgs.zlib ];
|
||||||
|
OPENSSL_STATIC = "1";
|
||||||
|
SQLITE3_STATIC = "1";
|
||||||
|
env.PKG_CONFIG_ALLOW_CROSS = "1";
|
||||||
|
# Keep build scripts on the native host and cross-link only the payload.
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export CARGO_TARGET_${pkgs.stdenv.buildPlatform.rust.cargoEnvVarTarget}_LINKER=${pkgs.stdenv.cc}/bin/cc
|
||||||
|
export CARGO_TARGET_${staticPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget}_LINKER=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}cc
|
||||||
|
export CC_${pkgs.stdenv.buildPlatform.rust.cargoEnvVarTarget}=${pkgs.stdenv.cc}/bin/cc
|
||||||
|
export CC_${staticPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget}=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}cc
|
||||||
|
export CC_${builtins.replaceStrings [ "-" ] [ "_" ] staticPkgs.stdenv.hostPlatform.rust.rustcTarget}=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}cc
|
||||||
|
export CXX_${builtins.replaceStrings [ "-" ] [ "_" ] staticPkgs.stdenv.hostPlatform.rust.rustcTarget}=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}c++
|
||||||
|
unset NIX_CFLAGS_COMPILE NIX_LDFLAGS
|
||||||
|
export CARGO_TARGET_${staticPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget}_RUSTFLAGS="-L native=${lib.getLib staticPkgs.zlib}/lib --remap-path-prefix=/nix/store=/usr/src"
|
||||||
|
cargo build --locked --offline --release -j "$NIX_BUILD_CORES" \
|
||||||
|
--target ${staticPkgs.stdenv.hostPlatform.rust.rustcTarget} ${lib.escapeShellArgs iota.cargoBuildFlags}
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p "$out/bin"
|
||||||
|
for binary in iota iota-daemon iota-updater iota-release iota-bundle; do
|
||||||
|
cp "target/${staticPkgs.stdenv.hostPlatform.rust.rustcTarget}/release/$binary" "$out/bin/"
|
||||||
|
done
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
dontUseCmakeConfigure = true;
|
||||||
|
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
|
||||||
|
doCheck = false;
|
||||||
|
# Reject accidental dynamic linkage before these binaries reach a release.
|
||||||
|
postFixup = ''
|
||||||
|
for binary in "$out"/bin/*; do
|
||||||
|
# Prebuilt Rust std retains compiler source paths in panic messages.
|
||||||
|
remove-references-to -t ${rust} "$binary"
|
||||||
|
if readelf -l "$binary" | grep -q INTERP || readelf -d "$binary" | grep -q NEEDED; then
|
||||||
|
echo "Non-portable ELF: $binary" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
allowedReferences = [ "out" ];
|
||||||
|
meta = iota.meta;
|
||||||
|
};
|
||||||
|
clientPackages = import ./client.nix {
|
||||||
|
inherit pkgs rust rustPlatform inputs hashes;
|
||||||
|
mtpSource = sources.mtp;
|
||||||
|
mtpVendor = vendors.mtp;
|
||||||
|
};
|
||||||
|
packages = {
|
||||||
|
inherit iota;
|
||||||
|
iota-portable = iotaPortable;
|
||||||
|
iota-bundle = pkgs.runCommand "iota-bundle" {} ''
|
||||||
|
mkdir -p "$out/bin" "$out/share/iota"
|
||||||
|
cp -r ${iota}/bin/. "$out/bin/"
|
||||||
|
cp -r ${inputs.iota}/systemd "$out/share/iota/"
|
||||||
|
cp -r ${sources.iota}/scripts "$out/share/iota/"
|
||||||
|
cp ${inputs.iota}/iota-updater/artifacts.tsv "$out/share/iota/"
|
||||||
|
cp -r ${iota}/share/iota/web "$out/share/iota/static-web"
|
||||||
|
cp ${sources.iota}/iota-installer/bundle-files.txt "$out/share/iota/"
|
||||||
|
'';
|
||||||
|
iota-daemon = iota.overrideAttrs {
|
||||||
|
pname = "iota-daemon";
|
||||||
|
cargoBuildFlags = [ "-p" "iota-daemon" ];
|
||||||
|
meta.mainProgram = "iota-daemon";
|
||||||
|
};
|
||||||
|
iota-ui = iota.overrideAttrs {
|
||||||
|
pname = "iota-ui";
|
||||||
|
cargoBuildFlags = [ "-p" "iota" ];
|
||||||
|
postInstall = iota.postInstall + ''mv "$out/bin/iota" "$out/bin/iota-ui"'';
|
||||||
|
meta.mainProgram = "iota-ui";
|
||||||
|
};
|
||||||
|
omikron = mkRust "omikron" [];
|
||||||
|
omega = mkRust "omega" [];
|
||||||
|
iota-container = pkgs.dockerTools.buildLayeredImage {
|
||||||
|
name = "tensamin/iota";
|
||||||
|
tag = "${inputs.iota.shortRev or "local"}";
|
||||||
|
contents = [ iotaPortable pkgs.cacert pkgs.dockerTools.binSh ];
|
||||||
|
fakeRootCommands = ''
|
||||||
|
mkdir -p var/lib/iota/config var/lib/iota/runtime tmp
|
||||||
|
chmod 1777 tmp
|
||||||
|
chown -R 1000:1000 var/lib/iota
|
||||||
|
'';
|
||||||
|
enableFakechroot = true;
|
||||||
|
config = {
|
||||||
|
Entrypoint = [ "${iotaPortable}/bin/iota-daemon" ];
|
||||||
|
User = "1000:1000";
|
||||||
|
WorkingDir = "/var/lib/iota";
|
||||||
|
Volumes = { "/var/lib/iota" = {}; };
|
||||||
|
ExposedPorts = { "1984/tcp" = {}; "1984/udp" = {}; };
|
||||||
|
Env = [
|
||||||
|
"HOME=/var/lib/iota"
|
||||||
|
"IOTA_DATA_ROOT=/var/lib/iota"
|
||||||
|
"IOTA_DEPLOYMENT_MODE=user_local"
|
||||||
|
"IOTA_ASSET_DIR=${iotaPortable}/share/iota/web"
|
||||||
|
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
omikron-container = pkgs.dockerTools.buildLayeredImage {
|
||||||
|
name = "tensamin/omikron";
|
||||||
|
tag = "${inputs.omikron.shortRev or "local"}";
|
||||||
|
contents = [ packages.omikron pkgs.cacert pkgs.dockerTools.binSh ];
|
||||||
|
config = {
|
||||||
|
Entrypoint = [ "${packages.omikron}/bin/omikron" ];
|
||||||
|
WorkingDir = "/var/lib/omikron";
|
||||||
|
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
omega-container = pkgs.dockerTools.buildLayeredImage {
|
||||||
|
name = "tensamin/omega";
|
||||||
|
tag = "${inputs.omega.shortRev or "local"}";
|
||||||
|
contents = [ packages.omega pkgs.cacert pkgs.dockerTools.binSh ];
|
||||||
|
config = {
|
||||||
|
Entrypoint = [ "${packages.omega}/bin/omega" ];
|
||||||
|
WorkingDir = "/var/lib/omega";
|
||||||
|
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
inherit (clientPackages) client client-web mtp-sdk;
|
||||||
|
default = packages.client;
|
||||||
|
update-all = pkgs.writeShellApplication {
|
||||||
|
name = "update-all";
|
||||||
|
runtimeInputs = [ pkgs.nix pkgs.git pkgs.python3 rust pkgs.nodejs pkgs.pnpm pkgs.coreutils ];
|
||||||
|
text = ''exec python ${../scripts/update-all.py} "$@"'';
|
||||||
|
};
|
||||||
|
} // lib.mapAttrs' (name: value: lib.nameValuePair "${name}-source" value) prepared
|
||||||
|
// lib.mapAttrs' (name: value: lib.nameValuePair "${name}-vendor" value) vendors
|
||||||
|
// { inherit (clientPackages) client-source client-deps sdk-deps; };
|
||||||
|
in {
|
||||||
|
inherit packages;
|
||||||
|
devShells = lib.genAttrs [ "iota" "omikron" "omega" "mtp" "client" ] (name: pkgs.mkShell {
|
||||||
|
packages = [ rust pkgs.git pkgs.cmake pkgs.perl pkgs.pkg-config pkgs.nodejs pkgs.pnpm pkgs.wasm-pack ];
|
||||||
|
buildInputs = [ pkgs.openssl pkgs.sqlite ] ++ lib.optional (name == "omega") pkgs.libmysqlclient;
|
||||||
|
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
|
||||||
|
}) // { default = pkgs.mkShell { packages = [ packages.update-all ]; }; };
|
||||||
|
}
|
||||||
8
packages/hashes.nix
Normal file
8
packages/hashes.nix
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
iota = "sha256-LYr2OqOSUX3AXwoJr15xtswpHRVz50evrrT7ep5FZog=";
|
||||||
|
omikron = "sha256-HFSZ6fEzMzHWFrjPBY08XaLC+OoNNIURflvO/CVaKZM=";
|
||||||
|
omega = "sha256-rvz6UoZlKPHvTsURLTBqSjuPvdwlKpmKBQ1+27fzC8I=";
|
||||||
|
mtp = "sha256-zfucKSWEIx3l1L9lTj1lidD/qE/HwueLBVJoND51MMU=";
|
||||||
|
client = "sha256-w6onBznxx/7bsIjodQz98tcsudcDNEtTPJgf2bmrXSU=";
|
||||||
|
sdk = "sha256-24mJCREdij/ha95AdmxOIsE/cHKkiBNoQCPANau1GcU=";
|
||||||
|
}
|
||||||
10385
packages/locks/client.yaml
Normal file
10385
packages/locks/client.yaml
Normal file
File diff suppressed because it is too large
Load diff
6163
packages/locks/iota.lock
Normal file
6163
packages/locks/iota.lock
Normal file
File diff suppressed because it is too large
Load diff
5677
packages/locks/mtp.lock
Normal file
5677
packages/locks/mtp.lock
Normal file
File diff suppressed because it is too large
Load diff
3359
packages/locks/omega.lock
Normal file
3359
packages/locks/omega.lock
Normal file
File diff suppressed because it is too large
Load diff
4180
packages/locks/omikron.lock
Normal file
4180
packages/locks/omikron.lock
Normal file
File diff suppressed because it is too large
Load diff
100
scripts/deploy-release.py
Normal file
100
scripts/deploy-release.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Commit only the infrastructure pin, deploy that commit, revert on failure."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
return subprocess.check_output(args, text=True, **kwargs).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
revision = run("git", "rev-parse", "HEAD")
|
||||||
|
repository = os.environ["NIXOS_FLAKE_REPOSITORY"]
|
||||||
|
branch = os.environ.get("NIXOS_FLAKE_BRANCH", "main")
|
||||||
|
server = os.environ["FORGEJO_SERVER_URL"].rstrip("/")
|
||||||
|
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repository):
|
||||||
|
raise ValueError("Invalid infrastructure repository")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="tensamin-deploy-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
askpass = root / "askpass"
|
||||||
|
askpass.write_text('#!/bin/sh\ncase "$1" in *Username*) printf "%s\\n" token;; *) printf "%s\\n" "$NIXOS_FLAKE_WRITE_TOKEN";; esac\n')
|
||||||
|
askpass.chmod(0o700)
|
||||||
|
env = os.environ | {"GIT_ASKPASS": str(askpass), "GIT_TERMINAL_PROMPT": "0"}
|
||||||
|
checkout = root / "infrastructure"
|
||||||
|
run("git", "clone", "--branch", branch, "--single-branch", f"{server}/{repository}.git", str(checkout), env=env)
|
||||||
|
original = run("git", "rev-parse", "HEAD", cwd=checkout)
|
||||||
|
# Fail before pushing if the forced command has not adopted the new contract.
|
||||||
|
wrapper = (checkout / "garten/tensamin-prod/services/deploy.nix").read_text()
|
||||||
|
if "<nixos-flake commit SHA>" not in wrapper:
|
||||||
|
raise RuntimeError("Deployment wrapper must accept <nixos-flake commit SHA> before promotion")
|
||||||
|
url = f"git+ssh://git@methanium.net/{os.environ['FORGEJO_REPOSITORY']}?ref=main&rev={revision}"
|
||||||
|
run("nix", "flake", "lock", "--override-input", "prod-pins", url, cwd=checkout)
|
||||||
|
lock = json.loads((checkout / "flake.lock").read_text())
|
||||||
|
if lock["nodes"][lock["nodes"]["root"]["inputs"]["prod-pins"]]["locked"]["rev"] != revision:
|
||||||
|
raise RuntimeError("Infrastructure pin mismatch")
|
||||||
|
run("git", "add", "flake.lock", cwd=checkout)
|
||||||
|
identity = ["git", "-c", "user.name=Tensamin releases", "-c", "user.email=releases@tensamin.net"]
|
||||||
|
changed = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=checkout, check=False).returncode
|
||||||
|
if changed not in {0, 1}:
|
||||||
|
raise RuntimeError("Unable to inspect infrastructure pin changes")
|
||||||
|
if changed:
|
||||||
|
run(*identity, "commit", "-m", f"tensamin-prod: pin {revision}", cwd=checkout)
|
||||||
|
commit = run("git", "rev-parse", "HEAD", cwd=checkout)
|
||||||
|
key = root / "deploy-key"
|
||||||
|
key.write_text(os.environ["TENSAMIN_PROD_DEPLOY_SSH_KEY"] + "\n")
|
||||||
|
key.chmod(0o600)
|
||||||
|
known = root / "known_hosts"
|
||||||
|
known.write_text(os.environ["TENSAMIN_SSH_KNOWN_HOSTS"] + "\n")
|
||||||
|
config = root / "ssh_config"
|
||||||
|
host = os.environ["TENSAMIN_PROD_DEPLOY_HOST"]
|
||||||
|
port = os.environ.get("TENSAMIN_PROD_DEPLOY_PORT", "22")
|
||||||
|
jump_host = os.environ.get("TENSAMIN_PROD_DEPLOY_JUMP_HOST", "")
|
||||||
|
jump_port = os.environ.get("TENSAMIN_PROD_DEPLOY_JUMP_PORT", "7930")
|
||||||
|
for hostname in [host, jump_host]:
|
||||||
|
if hostname and not re.fullmatch(r"[A-Za-z0-9_.:-]+", hostname):
|
||||||
|
raise ValueError("Invalid deployment SSH hostname")
|
||||||
|
for value in [port, jump_port]:
|
||||||
|
if not value.isdecimal() or not 1 <= int(value) <= 65535:
|
||||||
|
raise ValueError("Invalid deployment SSH port")
|
||||||
|
config.write_text(
|
||||||
|
f"Host tensamin-deploy\n HostName {host}\n Port {port}\n User deploy\n"
|
||||||
|
+ (" ProxyJump tensamin-deploy-jump\n" if jump_host else "")
|
||||||
|
+ (f"Host tensamin-deploy-jump\n HostName {jump_host}\n Port {jump_port}\n"
|
||||||
|
" User deploy-jump\n" if jump_host else "")
|
||||||
|
+ f'Host *\n IdentityFile "{key}"\n IdentitiesOnly yes\n'
|
||||||
|
f' StrictHostKeyChecking yes\n UserKnownHostsFile "{known}"\n'
|
||||||
|
" BatchMode yes\n ConnectTimeout 15\n"
|
||||||
|
)
|
||||||
|
ssh = ["ssh", "-F", str(config), "-T", "tensamin-deploy"]
|
||||||
|
# Prepare and validate SSH before publishing an infrastructure change.
|
||||||
|
run("ssh", "-G", "-F", str(config), "tensamin-deploy")
|
||||||
|
if changed:
|
||||||
|
run("git", "push", "origin", f"HEAD:refs/heads/{branch}", cwd=checkout, env=env)
|
||||||
|
try:
|
||||||
|
subprocess.run([*ssh, commit], check=True)
|
||||||
|
# Publication is inside the transaction, so failed publication restores the pin.
|
||||||
|
subprocess.run(["python3", "scripts/release.py", "publish", "--channel", "stable",
|
||||||
|
"--tag", os.environ["RELEASE_TAG"]], check=True)
|
||||||
|
except BaseException:
|
||||||
|
if not changed:
|
||||||
|
raise
|
||||||
|
# A normal revert preserves unrelated concurrent infrastructure commits.
|
||||||
|
run("git", "fetch", "origin", branch, cwd=checkout, env=env)
|
||||||
|
run("git", "reset", "--hard", f"origin/{branch}", cwd=checkout)
|
||||||
|
run(*identity, "revert", "--no-edit", commit, cwd=checkout)
|
||||||
|
run("git", "push", "origin", f"HEAD:refs/heads/{branch}", cwd=checkout, env=env)
|
||||||
|
rollback = run("git", "rev-parse", "HEAD", cwd=checkout)
|
||||||
|
subprocess.run([*ssh, rollback], check=True)
|
||||||
|
raise
|
||||||
|
Path("release-out/deployment.json").write_text(json.dumps({
|
||||||
|
"previous_infrastructure": original, "infrastructure": commit, "prod_pins": revision,
|
||||||
|
}, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
58
scripts/release-build.sh
Normal file
58
scripts/release-build.sh
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root=$(pwd)
|
||||||
|
channel=${1:?Usage: release-build.sh CHANNEL OUTPUT_DIRECTORY}
|
||||||
|
out=$(realpath -m "${2:?Output directory required}")
|
||||||
|
[[ $channel == stable || $channel == canary ]]
|
||||||
|
mkdir -p "$out"
|
||||||
|
export TENSAMIN_CHANNEL=$channel ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||||
|
export TENSAMIN_ANDROID_VERSION_CODE=${RELEASE_SEQUENCE:?Release sequence required}
|
||||||
|
system=${3:-$(nix eval --impure --raw --expr 'builtins.currentSystem')}
|
||||||
|
expr="import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"packages\"; channel = \"$channel\"; system = \"$system\"; }"
|
||||||
|
packages=$(nix build --impure --no-link --print-out-paths --expr "$expr")
|
||||||
|
nix build --impure --no-link --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"checks\"; system = \"$system\"; }"
|
||||||
|
arch=$system
|
||||||
|
arch=${arch%-linux}
|
||||||
|
printf '%s\n' "$packages" > "$out/packages-$arch.txt"
|
||||||
|
for name in iota iota-daemon iota-ui omikron omega client client-web mtp-sdk; do
|
||||||
|
# Nix closures, unlike a plain copy of a Nix binary, retain runtime libraries.
|
||||||
|
mapfile -t closure < <(nix-store --query --requisites "$packages/$name")
|
||||||
|
nix-store --export "${closure[@]}" | gzip -n > "$out/$name-linux-$arch.nar.gz"
|
||||||
|
done
|
||||||
|
for binary in iota iota-daemon iota-updater; do
|
||||||
|
cp "$packages/iota-portable/bin/$binary" "$out/$binary-linux-$arch"
|
||||||
|
done
|
||||||
|
tar -czf "$out/iota-portable-linux-$arch.tar.gz" -C "$packages/iota-portable" bin share
|
||||||
|
cp -L "$packages/iota-container" "$out/iota-image-linux-$arch.tar.gz"
|
||||||
|
cp -L "$packages/omikron-container" "$out/omikron-image-linux-$arch.tar.gz"
|
||||||
|
cp -L "$packages/omega-container" "$out/omega-image-linux-$arch.tar.gz"
|
||||||
|
cp "$packages/iota-portable/bin/iota-release" "$out/iota-release-linux-$arch"
|
||||||
|
cp "$packages/iota-portable/bin/iota-bundle" "$out/iota-bundle-linux-$arch"
|
||||||
|
cp -rL "$packages/iota-bundle/share/iota" "$out/iota-contract"
|
||||||
|
# The helpers' relative script paths are part of the Iota contract.
|
||||||
|
mkdir -p "$out/iota-contract/iota-updater" "$out/iota-contract/iota-installer"
|
||||||
|
mv "$out/iota-contract/artifacts.tsv" "$out/iota-contract/iota-updater/"
|
||||||
|
mv "$out/iota-contract/bundle-files.txt" "$out/iota-contract/iota-installer/"
|
||||||
|
mkdir -p "$out/iota-contract/static"
|
||||||
|
mv "$out/iota-contract/static-web" "$out/iota-contract/static/web"
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
source=$(nix build --no-link --print-out-paths ".#packages.$system.client-source")
|
||||||
|
cp -r "$source/." "$work/"
|
||||||
|
chmod -R u+w "$work"
|
||||||
|
rm -rf "$work/.mtp-sdk"
|
||||||
|
cp -rL "$packages/mtp-sdk" "$work/.mtp-sdk"
|
||||||
|
chmod -R u+w "$work/.mtp-sdk"
|
||||||
|
export RELEASE_ROOT=$root RELEASE_OUT=$out RELEASE_WORK=$work RELEASE_ARCH=$arch
|
||||||
|
export TENSAMIN_RELEASE_TAG=${RELEASE_TAG:?} TENSAMIN_RELEASE_VERSION=$RELEASE_TAG
|
||||||
|
export FORGEJO_RELEASE_ASSET_BASE_URL="${FORGEJO_SERVER_URL:?}/${FORGEJO_REPOSITORY:?}/releases/download/$RELEASE_TAG"
|
||||||
|
pushd "$work" >/dev/null
|
||||||
|
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"electron\"; system = \"$system\"; }" \
|
||||||
|
--command bash "$root/scripts/release-client.sh" desktop
|
||||||
|
if [[ $arch == x86_64 ]]; then
|
||||||
|
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"tauri\"; }" \
|
||||||
|
--command bash "$root/scripts/release-client.sh" android
|
||||||
|
fi
|
||||||
|
popd >/dev/null
|
||||||
35
scripts/release-client.sh
Normal file
35
scripts/release-client.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "${RELEASE_WORK:?}"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
case ${1:?} in
|
||||||
|
desktop)
|
||||||
|
pnpm run ci
|
||||||
|
electron_arch=x64
|
||||||
|
[[ $RELEASE_ARCH != aarch64 ]] || electron_arch=arm64
|
||||||
|
pnpm --dir apps/electron exec electron-builder --config electron-builder.config.cjs \
|
||||||
|
--linux --"$electron_arch" --publish never
|
||||||
|
pnpm run copy-releases
|
||||||
|
for asset in releases/*; do
|
||||||
|
[[ $(basename "$asset") == SHA256SUMS ]] && continue
|
||||||
|
name=$(basename "$asset")
|
||||||
|
[[ $name != electron-release-metadata.json ]] || name="electron-release-metadata-$RELEASE_ARCH.json"
|
||||||
|
cp "$asset" "$RELEASE_OUT/$name"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
android)
|
||||||
|
: "${ANDROID_KEYSTORE_BASE64:?}" "${ANDROID_KEY_ALIAS:?}" "${ANDROID_KEY_PASSWORD:?}" "${ANDROID_STORE_PASSWORD:?}"
|
||||||
|
umask 077
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > release.keystore
|
||||||
|
# Gradle resolves the keystore properties relative to the client root.
|
||||||
|
printf 'storeFile=release.keystore\nkeyAlias=%s\nkeyPassword=%s\nstorePassword=%s\n' \
|
||||||
|
"$ANDROID_KEY_ALIAS" "$ANDROID_KEY_PASSWORD" "$ANDROID_STORE_PASSWORD" > keystore.properties
|
||||||
|
trap 'rm -f release.keystore keystore.properties' EXIT
|
||||||
|
cargo test --locked --manifest-path apps/tauri/src-tauri/Cargo.toml
|
||||||
|
pnpm run build:mobile
|
||||||
|
apk=apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||||
|
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose "$apk"
|
||||||
|
cp "$apk" "$RELEASE_OUT/Tensamin-$TENSAMIN_RELEASE_TAG.apk"
|
||||||
|
;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
69
scripts/release-env.nix
Normal file
69
scripts/release-env.nix
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
{ root, kind ? "tools", channel ? "canary", system ? builtins.currentSystem }:
|
||||||
|
let
|
||||||
|
central = builtins.getFlake (toString root);
|
||||||
|
pkgs = import central.inputs.nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ central.inputs.rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
project = central.lib.mkPackages { inherit system; };
|
||||||
|
# Reuse only the client's tool shells, with central nixpkgs and Rust inputs.
|
||||||
|
# Client builds below always consume client-source and mtp-sdk from prod-pins.
|
||||||
|
clientTools = (import (central.inputs.client + "/flake.nix")).outputs {
|
||||||
|
self = central.inputs.client;
|
||||||
|
nixpkgs = central.inputs.nixpkgs // {
|
||||||
|
# The client's old SDK platform-tools pin is absent in central nixpkgs.
|
||||||
|
outPath = pkgs.runCommand "release-client-nixpkgs" {} ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
cat > "$out/default.nix" <<'EOF'
|
||||||
|
args: let
|
||||||
|
pkgs = import ${central.inputs.nixpkgs} args;
|
||||||
|
in pkgs // { androidenv = pkgs.androidenv // {
|
||||||
|
composeAndroidPackages = options: pkgs.androidenv.composeAndroidPackages
|
||||||
|
(options // { platformToolsVersion = "37.0.1"; });
|
||||||
|
}; }
|
||||||
|
EOF
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
rust-overlay = central.inputs.rust-overlay;
|
||||||
|
};
|
||||||
|
branded = name: project.packages.${name}.overrideAttrs {
|
||||||
|
TENSAMIN_CHANNEL = channel;
|
||||||
|
};
|
||||||
|
checked = name: project.packages.${name}.overrideAttrs (old: {
|
||||||
|
doCheck = true;
|
||||||
|
installPhase = ''mkdir -p "$out"'';
|
||||||
|
nativeBuildInputs = old.nativeBuildInputs ++ project.devShells.${name}.nativeBuildInputs;
|
||||||
|
preBuild = (old.preBuild or "") + ''
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
|
||||||
|
'';
|
||||||
|
cargoTestFlags = [ "--workspace" ];
|
||||||
|
});
|
||||||
|
mtpCheck = project.packages.iota.overrideAttrs (old: {
|
||||||
|
pname = "mtp-checks";
|
||||||
|
src = project.packages.mtp-sdk.src;
|
||||||
|
cargoDeps = project.packages.mtp-vendor;
|
||||||
|
cargoBuildFlags = [ "--workspace" ];
|
||||||
|
cargoTestFlags = [ "--workspace" ];
|
||||||
|
doCheck = true;
|
||||||
|
installPhase = ''mkdir -p "$out"'';
|
||||||
|
nativeBuildInputs = old.nativeBuildInputs ++ project.devShells.mtp.nativeBuildInputs;
|
||||||
|
preBuild = ''
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
in
|
||||||
|
if kind == "tools" then pkgs.mkShell {
|
||||||
|
packages = with pkgs; [ nix git (python3.withPackages (p: [ p.pyyaml ])) bash coreutils jq zip gnutar gzip openssh skopeo shellcheck ruff ];
|
||||||
|
} else if kind == "electron" || kind == "tauri" then clientTools.devShells.${system}.${kind}
|
||||||
|
else if kind == "checks" then pkgs.linkFarm "release-checks" (map (name: {
|
||||||
|
inherit name;
|
||||||
|
path = checked name;
|
||||||
|
}) [ "iota" "omikron" "omega" ] ++ [ { name = "mtp"; path = mtpCheck; } ])
|
||||||
|
else if kind == "packages" then pkgs.linkFarm "release-packages" (map (name: {
|
||||||
|
inherit name;
|
||||||
|
path = if builtins.elem name [ "client" "client-web" ] then branded name else project.packages.${name};
|
||||||
|
}) [ "iota" "iota-portable" "iota-bundle" "iota-daemon" "iota-ui" "omikron" "omega"
|
||||||
|
"iota-container" "omikron-container" "omega-container" "client" "client-web" "mtp-sdk" ])
|
||||||
|
else throw "Unknown release environment ${kind}"
|
||||||
335
scripts/release.py
Normal file
335
scripts/release.py
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
"""Forgejo release staging, provenance selection, signing and channel refresh."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
return subprocess.check_output(args, text=True, **kwargs).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, data):
|
||||||
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
class Forgejo:
|
||||||
|
def __init__(self):
|
||||||
|
self.server = os.environ["FORGEJO_SERVER_URL"].rstrip("/")
|
||||||
|
self.repo = os.environ["FORGEJO_REPOSITORY"]
|
||||||
|
self.base = f"{self.server}/api/v1/repos/{self.repo}"
|
||||||
|
self.token = os.environ["RELEASE_TOKEN"]
|
||||||
|
|
||||||
|
def request(self, path, method="GET", data=None, content_type="application/json", raw=False):
|
||||||
|
url = path if path.startswith("https://") else self.base + path
|
||||||
|
# Do not forward the API token to an asset redirect on another host.
|
||||||
|
if urllib.parse.urlparse(url).netloc != urllib.parse.urlparse(self.server).netloc:
|
||||||
|
raise ValueError("Unexpected asset host")
|
||||||
|
if data is not None and not isinstance(data, bytes):
|
||||||
|
data = json.dumps(data).encode()
|
||||||
|
request = urllib.request.Request(url, data=data, method=method, headers={
|
||||||
|
"Authorization": f"token {self.token}", "Content-Type": content_type,
|
||||||
|
})
|
||||||
|
class SameHostRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
if urllib.parse.urlparse(newurl).netloc != urllib.parse.urlparse(req.full_url).netloc:
|
||||||
|
raise ValueError("Refusing authenticated cross-host redirect")
|
||||||
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||||
|
|
||||||
|
with urllib.request.build_opener(SameHostRedirect()).open(request, timeout=120) as response:
|
||||||
|
body = response.read()
|
||||||
|
return json.loads(body) if body and not raw and "json" in response.headers.get("Content-Type", "") else body
|
||||||
|
|
||||||
|
def release(self, tag, missing=False):
|
||||||
|
try:
|
||||||
|
return self.request("/releases/tags/" + urllib.parse.quote(tag, safe=""))
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if missing and error.code == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
def assets(self, release):
|
||||||
|
result = []
|
||||||
|
for page in range(1, 100):
|
||||||
|
batch = self.request(f"/releases/{release['id']}/assets?limit=50&page={page}")
|
||||||
|
result.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
return result
|
||||||
|
raise RuntimeError("Too many release assets")
|
||||||
|
|
||||||
|
def download(self, release, name):
|
||||||
|
asset = next(asset for asset in self.assets(release) if asset["name"] == name)
|
||||||
|
return self.request(asset["browser_download_url"], raw=True)
|
||||||
|
|
||||||
|
def upload(self, release, path):
|
||||||
|
# Forgejo's attachment API takes multipart/form-data, not raw bytes.
|
||||||
|
boundary = "tensamin-" + os.urandom(16).hex()
|
||||||
|
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; '
|
||||||
|
f'filename="{path.name}"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()
|
||||||
|
body += path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
return self.request(f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(path.name),
|
||||||
|
"POST", body, "multipart/form-data; boundary=" + boundary)
|
||||||
|
|
||||||
|
|
||||||
|
def provenance(directory, channel, tag):
|
||||||
|
lock = json.loads(Path("flake.lock").read_text())
|
||||||
|
sources = {name: lock["nodes"][node]["locked"]
|
||||||
|
for name, node in lock["nodes"]["root"]["inputs"].items() if isinstance(node, str)}
|
||||||
|
files = {}
|
||||||
|
for path in sorted(directory.iterdir()):
|
||||||
|
if path.is_file() and path.name not in {"release.json", "SHA256SUMS"}:
|
||||||
|
files[path.name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"size": path.stat().st_size}
|
||||||
|
write(directory / "release.json", {
|
||||||
|
"schema": 1, "channel": channel, "tag": tag, "revision": run("git", "rev-parse", "HEAD"),
|
||||||
|
"run_id": os.environ["FORGEJO_RUN_ID"], "sources": sources,
|
||||||
|
"lock_sha256": hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest(),
|
||||||
|
"architectures": [arch for arch in ["x86_64", "aarch64"]
|
||||||
|
if (directory / f"iota-linux-{arch}").exists()],
|
||||||
|
"artifacts": files,
|
||||||
|
})
|
||||||
|
(directory / "SHA256SUMS").write_text("".join(
|
||||||
|
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n"
|
||||||
|
for path in sorted(directory.iterdir()) if path.is_file() and path.name != "SHA256SUMS"))
|
||||||
|
|
||||||
|
|
||||||
|
def sign(directory, channel, tag, sequence):
|
||||||
|
api = Forgejo()
|
||||||
|
old = api.release(channel, missing=True)
|
||||||
|
if old:
|
||||||
|
for asset in api.assets(old):
|
||||||
|
if re.fullmatch(r"iota-update-linux-(x86_64|aarch64)\.json", asset["name"]):
|
||||||
|
manifest = json.loads(api.download(old, asset["name"]))
|
||||||
|
if sequence <= manifest["release_sequence"]:
|
||||||
|
raise RuntimeError("Channel sequence must strictly increase")
|
||||||
|
source_sha = json.loads(Path("flake.lock").read_text())
|
||||||
|
source_sha = source_sha["nodes"][source_sha["nodes"]["root"]["inputs"]["iota"]]["locked"]["rev"]
|
||||||
|
os.environ["IOTA_RELEASE_SOURCE_SHA"] = source_sha
|
||||||
|
now = dt.datetime.now(dt.timezone.utc)
|
||||||
|
published = now.isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
expires = (now + dt.timedelta(days=14)).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
host = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem").removesuffix("-linux")
|
||||||
|
signer = directory / f"iota-release-linux-{host}"
|
||||||
|
verifier = directory / f"iota-bundle-linux-{host}"
|
||||||
|
signer.chmod(0o755)
|
||||||
|
verifier.chmod(0o755)
|
||||||
|
base = f"{api.server}/{api.repo}/releases/download/{tag}"
|
||||||
|
contract = directory / "iota-contract/scripts"
|
||||||
|
for arch in ["x86_64", "aarch64"]:
|
||||||
|
if not (directory / f"iota-linux-{arch}").exists():
|
||||||
|
continue
|
||||||
|
binaries = directory / f"bin-{arch}"
|
||||||
|
binaries.mkdir(exist_ok=True)
|
||||||
|
for binary in ["iota", "iota-daemon", "iota-updater"]:
|
||||||
|
target = binaries / binary
|
||||||
|
target.write_bytes((directory / f"{binary}-linux-{arch}").read_bytes())
|
||||||
|
target.chmod(0o755)
|
||||||
|
manifest = directory / f"iota-update-linux-{arch}.json"
|
||||||
|
run("bash", str(contract / "build-update-manifest.sh"), str(binaries),
|
||||||
|
os.environ["IOTA_BASE_VERSION"], channel, str(sequence), published, expires,
|
||||||
|
"linux", arch, base, str(manifest))
|
||||||
|
public = run(str(signer), "sign", str(manifest), str(manifest) + ".sig")
|
||||||
|
if public != os.environ["IOTA_RELEASE_PUBLIC_KEY"]:
|
||||||
|
raise RuntimeError("Release signing key does not match the pinned public key")
|
||||||
|
url = f"{api.server}/{api.repo}/releases/download/{channel}/{manifest.name}"
|
||||||
|
bundle = directory / f"iota-linux-{arch}.zip"
|
||||||
|
bundle.unlink(missing_ok=True)
|
||||||
|
run("bash", str(contract / "build-release-bundle.sh"), str(binaries),
|
||||||
|
json.loads(manifest.read_text())["product_version"], str(manifest), url, public,
|
||||||
|
url + ".sig", channel, os.environ.get("IOTA_RELEASE_SIGNING_KEY_ID", "primary"), str(bundle))
|
||||||
|
run(str(verifier), str(bundle))
|
||||||
|
|
||||||
|
|
||||||
|
def stage(directory, channel, tag):
|
||||||
|
api = Forgejo()
|
||||||
|
if api.release(tag, missing=True):
|
||||||
|
raise RuntimeError("Immutable release tag already exists")
|
||||||
|
release = api.request("/releases", "POST", {
|
||||||
|
"tag_name": tag, "target_commitish": run("git", "rev-parse", "HEAD"),
|
||||||
|
"name": tag, "body": "Verified central source build. See release.json for provenance.",
|
||||||
|
"draft": True, "prerelease": channel == "canary",
|
||||||
|
})
|
||||||
|
for path in sorted(directory.iterdir()):
|
||||||
|
if path.is_file():
|
||||||
|
api.upload(release, path)
|
||||||
|
# Validate staged attachment bytes before any deployment or visibility change.
|
||||||
|
for path in sorted(directory.iterdir()):
|
||||||
|
if path.is_file() and hashlib.sha256(api.download(release, path.name)).digest() != hashlib.sha256(path.read_bytes()).digest():
|
||||||
|
raise RuntimeError(f"Staged asset mismatch: {path.name}")
|
||||||
|
write(directory / "stage.json", {"id": release["id"], "tag": tag})
|
||||||
|
|
||||||
|
|
||||||
|
def publish(directory, channel, tag):
|
||||||
|
api = Forgejo()
|
||||||
|
immutable = api.release(tag)
|
||||||
|
if not immutable["draft"]:
|
||||||
|
raise RuntimeError("Expected a staged draft")
|
||||||
|
registry = urllib.parse.urlparse(api.server).netloc
|
||||||
|
auth = directory / ".registry-auth.json"
|
||||||
|
try:
|
||||||
|
subprocess.run(["skopeo", "login", "--authfile", str(auth), "--username",
|
||||||
|
os.environ["FORGEJO_REGISTRY_USER"], "--password-stdin", registry],
|
||||||
|
input=api.token, text=True, check=True)
|
||||||
|
for image in directory.glob("*-image-linux-*.tar.gz"):
|
||||||
|
service, arch = image.name.split("-image-linux-")
|
||||||
|
arch = arch.removesuffix(".tar.gz")
|
||||||
|
run("skopeo", "copy", "--authfile", str(auth), "docker-archive:" + str(image),
|
||||||
|
f"docker://{registry}/{api.repo.split('/')[0]}/{service}:{tag}-{arch}")
|
||||||
|
api.request(f"/releases/{immutable['id']}", "PATCH", {"draft": False})
|
||||||
|
try:
|
||||||
|
update_pointer(api, directory, channel, tag)
|
||||||
|
except Exception:
|
||||||
|
api.request(f"/releases/{immutable['id']}", "PATCH", {"draft": True})
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
auth.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_pointer(api, directory, channel, tag):
|
||||||
|
pointer = api.release(channel, missing=True)
|
||||||
|
new = pointer is None
|
||||||
|
if new:
|
||||||
|
pointer = api.request("/releases", "POST", {
|
||||||
|
"tag_name": channel, "target_commitish": run("git", "rev-parse", "HEAD"),
|
||||||
|
"name": channel, "draft": True, "prerelease": channel == "canary",
|
||||||
|
})
|
||||||
|
backup = []
|
||||||
|
names = {path.name for path in directory.glob("iota-update-linux-*.json*")}
|
||||||
|
names.update({"channel.json", "electron-release-metadata.json"})
|
||||||
|
# A refresh never moves binaries or changes the immutable release identity.
|
||||||
|
write(directory / "channel.json", {"channel": channel, "tag": tag,
|
||||||
|
"url": f"{api.server}/{api.repo}/releases/tag/{tag}"})
|
||||||
|
electron = [json.loads(path.read_text()) for path in directory.glob("electron-release-metadata-*.json")]
|
||||||
|
if electron:
|
||||||
|
combined = electron[0]
|
||||||
|
combined["artifacts"] = [artifact for entry in electron for artifact in entry["artifacts"]]
|
||||||
|
write(directory / "electron-release-metadata.json", combined)
|
||||||
|
else:
|
||||||
|
names.discard("electron-release-metadata.json")
|
||||||
|
# Remove stale architecture manifests too, so they cannot advertise another build.
|
||||||
|
old_assets = api.assets(pointer)
|
||||||
|
names.update(asset["name"] for asset in old_assets if asset["name"].startswith("iota-update-linux-"))
|
||||||
|
try:
|
||||||
|
for asset in old_assets:
|
||||||
|
if asset["name"] in names:
|
||||||
|
backup.append((asset["name"], api.download(pointer, asset["name"])))
|
||||||
|
api.request(f"/releases/{pointer['id']}/assets/{asset['id']}", "DELETE")
|
||||||
|
for name in sorted(names):
|
||||||
|
path = directory / name
|
||||||
|
if path.exists():
|
||||||
|
api.upload(pointer, path)
|
||||||
|
api.request(f"/releases/{pointer['id']}", "PATCH", {"draft": False,
|
||||||
|
"body": f"Current {channel} build: {tag}. Signed metadata refreshed automatically."})
|
||||||
|
except Exception:
|
||||||
|
for asset in api.assets(pointer):
|
||||||
|
if asset["name"] in names:
|
||||||
|
api.request(f"/releases/{pointer['id']}/assets/{asset['id']}", "DELETE")
|
||||||
|
for name, raw in backup:
|
||||||
|
path = directory / name
|
||||||
|
path.write_bytes(raw)
|
||||||
|
api.upload(pointer, path)
|
||||||
|
if new:
|
||||||
|
api.request(f"/releases/{pointer['id']}", "DELETE")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def select(tag):
|
||||||
|
if not re.fullmatch(r"canary-[0-9a-f]{40}-[0-9]+", tag):
|
||||||
|
raise ValueError("Select an immutable canary tag")
|
||||||
|
api = Forgejo()
|
||||||
|
release = api.release(tag)
|
||||||
|
data = json.loads(api.download(release, "release.json"))
|
||||||
|
if release["draft"] or not release["prerelease"] or data["channel"] != "canary" or data["tag"] != tag:
|
||||||
|
raise RuntimeError("Not a published canary")
|
||||||
|
result = api.request(f"/actions/runs/{data['run_id']}")
|
||||||
|
result = result.get("workflow_run", result)
|
||||||
|
if result["conclusion"] != "success" or result["head_sha"] != data["revision"]:
|
||||||
|
raise RuntimeError("Canary workflow has not completed successfully")
|
||||||
|
revision = data["revision"]
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
||||||
|
raise ValueError("Invalid source revision")
|
||||||
|
run("git", "fetch", "origin", revision)
|
||||||
|
run("git", "checkout", "--detach", revision)
|
||||||
|
if hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest() != data["lock_sha256"]:
|
||||||
|
raise RuntimeError("Canary lock provenance mismatch")
|
||||||
|
Path(".release-selection.json").write_text(json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("command", choices=["select", "sign", "stage", "publish", "refresh", "provenance"])
|
||||||
|
parser.add_argument("--directory", type=Path, default=Path("release-out"))
|
||||||
|
parser.add_argument("--channel", choices=["stable", "canary"], default="canary")
|
||||||
|
parser.add_argument("--tag")
|
||||||
|
args = parser.parse_args()
|
||||||
|
directory = args.directory.resolve()
|
||||||
|
if args.command == "select":
|
||||||
|
select(args.tag)
|
||||||
|
return
|
||||||
|
sequence = int(os.environ.get("RELEASE_SEQUENCE", str(int(time.time()))))
|
||||||
|
if not 0 < sequence <= 2100000000:
|
||||||
|
raise ValueError("Sequence exceeds Android version-code range")
|
||||||
|
if args.command == "refresh":
|
||||||
|
api = Forgejo()
|
||||||
|
pointer = api.release(args.channel, missing=True)
|
||||||
|
if pointer is None:
|
||||||
|
return
|
||||||
|
tag = json.loads(api.download(pointer, "channel.json"))["tag"]
|
||||||
|
release = api.release(tag)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = json.loads(api.download(release, "release.json"))
|
||||||
|
run("git", "fetch", "origin", data["revision"])
|
||||||
|
run("git", "checkout", "--detach", data["revision"])
|
||||||
|
if hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest() != data["lock_sha256"]:
|
||||||
|
raise RuntimeError("Refresh lock provenance mismatch")
|
||||||
|
for asset in api.assets(release):
|
||||||
|
path = directory / asset["name"]
|
||||||
|
if path.name != asset["name"]:
|
||||||
|
raise ValueError("Unsafe asset name")
|
||||||
|
path.write_bytes(api.download(release, path.name))
|
||||||
|
for name, expected in data["artifacts"].items():
|
||||||
|
path = directory / name
|
||||||
|
if hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]:
|
||||||
|
raise RuntimeError(f"Immutable asset mismatch: {name}")
|
||||||
|
host = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem").removesuffix("-linux")
|
||||||
|
# Helpers are Nix-linked binaries. Restore their exact runtime closure.
|
||||||
|
with (directory / f"iota-linux-{host}.nar.gz").open("rb") as archive:
|
||||||
|
unzip = subprocess.Popen(["gzip", "-dc"], stdin=archive, stdout=subprocess.PIPE)
|
||||||
|
subprocess.run(["nix-store", "--import"], stdin=unzip.stdout, check=True)
|
||||||
|
unzip.stdout.close()
|
||||||
|
if unzip.wait() != 0:
|
||||||
|
raise RuntimeError("Unable to restore release helper closure")
|
||||||
|
# Recover the exact contract from the immutable source revision.
|
||||||
|
bundle = run("nix", "build", "--no-link", "--print-out-paths", ".#iota-bundle")
|
||||||
|
run("cp", "-rL", bundle + "/share/iota", str(directory / "iota-contract"))
|
||||||
|
(directory / "iota-contract/static").mkdir(exist_ok=True)
|
||||||
|
(directory / "iota-contract/static-web").rename(directory / "iota-contract/static/web")
|
||||||
|
for name, destination in [("artifacts.tsv", "iota-updater"), ("bundle-files.txt", "iota-installer")]:
|
||||||
|
(directory / "iota-contract" / destination).mkdir(exist_ok=True)
|
||||||
|
(directory / "iota-contract" / name).rename(directory / "iota-contract" / destination / name)
|
||||||
|
sign(directory, args.channel, tag, sequence)
|
||||||
|
update_pointer(api, directory, args.channel, tag)
|
||||||
|
return
|
||||||
|
if not args.tag:
|
||||||
|
parser.error("--tag is required")
|
||||||
|
if args.command == "sign":
|
||||||
|
sign(directory, args.channel, args.tag, sequence)
|
||||||
|
elif args.command == "provenance":
|
||||||
|
provenance(directory, args.channel, args.tag)
|
||||||
|
elif args.command == "stage":
|
||||||
|
stage(directory, args.channel, args.tag)
|
||||||
|
elif args.command == "publish":
|
||||||
|
publish(directory, args.channel, args.tag)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
114
scripts/releases.md
Normal file
114
scripts/releases.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Central release setup
|
||||||
|
|
||||||
|
Only prod-pins publishes releases. Pushes to main and manual canary runs validate
|
||||||
|
the locked, prepared Rust and client sources, then build combined immutable
|
||||||
|
releases. Stable dispatch requires an immutable `canary-FULL_PROD_SHA-RUN_ID` tag
|
||||||
|
whose central workflow completed successfully. Promotion checks out that exact
|
||||||
|
prod-pins revision, including dependency locks and hashes. It rebuilds with stable
|
||||||
|
client branding. No input update command runs during promotion.
|
||||||
|
|
||||||
|
## Runner and credentials
|
||||||
|
|
||||||
|
Use a trusted `nixos` runner with Nix, Git and Bash available for bootstrap.
|
||||||
|
The workflow's tools use the central nixpkgs and Rust overlay. Source inputs
|
||||||
|
require SSH read access to every locked repository. Configure these secrets:
|
||||||
|
|
||||||
|
- `TENSAMIN_SOURCE_SSH_KEY`, read access to the pinned repositories.
|
||||||
|
- `TENSAMIN_RELEASE_TOKEN`, prod-pins release and package registry write access,
|
||||||
|
and Actions run read access.
|
||||||
|
- `IOTA_RELEASE_SIGNING_KEY`, 64 hex characters encoding the Ed25519 seed.
|
||||||
|
- `TENSAMIN_PROD_DEPLOY_SSH_KEY`, key authorized for the server's forced command.
|
||||||
|
- `NIXOS_FLAKE_WRITE_TOKEN`, infrastructure repository read and write access.
|
||||||
|
- `ANDROID_KEYSTORE_BASE64`, the existing Android keystore encoded as base64.
|
||||||
|
- `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `ANDROID_STORE_PASSWORD`, matching
|
||||||
|
the existing Android signing identity. Map existing secret names to these
|
||||||
|
workflow environment entries if their names differ.
|
||||||
|
|
||||||
|
Configure repository variables:
|
||||||
|
|
||||||
|
- `IOTA_RELEASE_PUBLIC_KEY`, pinned public key, 64 hex characters.
|
||||||
|
- `IOTA_RELEASE_SIGNING_KEY_ID`, default `primary`.
|
||||||
|
- `IOTA_BASE_VERSION`, default `0.1.0`.
|
||||||
|
- `FORGEJO_REGISTRY_USER`, token owner's Forgejo username.
|
||||||
|
- `NIXOS_FLAKE_REPOSITORY`, infrastructure repository as `owner/repository`.
|
||||||
|
- `NIXOS_FLAKE_BRANCH`, default `main`.
|
||||||
|
- `TENSAMIN_PROD_DEPLOY_HOST`, deployment SSH hostname.
|
||||||
|
- `TENSAMIN_PROD_DEPLOY_PORT`, default `22`.
|
||||||
|
- `TENSAMIN_PROD_DEPLOY_JUMP_HOST`, optional SSH jump hostname, `methanium.net`
|
||||||
|
for production. The jump user is `deploy-jump`; both hops use the deploy key.
|
||||||
|
- `TENSAMIN_PROD_DEPLOY_JUMP_PORT`, default `7930`.
|
||||||
|
- `TENSAMIN_SSH_KNOWN_HOSTS`, verified host keys for source Git and deployment.
|
||||||
|
|
||||||
|
For the production VM set `TENSAMIN_PROD_DEPLOY_HOST=10.201.0.10` and
|
||||||
|
`TENSAMIN_PROD_DEPLOY_PORT=22`. `TENSAMIN_SSH_KNOWN_HOSTS` must contain verified
|
||||||
|
entries for `methanium.net` on source Git port 22, `[methanium.net]:7930` for the
|
||||||
|
jump host, and `10.201.0.10` for the guest on port 22. These are separate host
|
||||||
|
identities and may have different keys. Include any other locked source SSH
|
||||||
|
hostnames too. The generated SSH config applies the key and known-hosts file to
|
||||||
|
both hops, including rollback deployment.
|
||||||
|
|
||||||
|
The infrastructure forced command must accept `<nixos-flake commit SHA>`, fetch
|
||||||
|
and deploy precisely that commit, validate its prod-pins lock, and return success
|
||||||
|
only after activation and application health checks. It must restore the prior
|
||||||
|
system and checkout on failure. `deploy-release.py` deliberately rejects the old
|
||||||
|
wrapper that accepts `<prod-pins commit SHA>`. It commits only infrastructure
|
||||||
|
`flake.lock`, pushes without force, passes that infrastructure commit through SSH,
|
||||||
|
then publishes stable. If deployment or publication fails, it reverts the pin
|
||||||
|
with a normal Git commit and deploys the rollback commit. A revert conflict or
|
||||||
|
unreachable server fails visibly and needs operator recovery.
|
||||||
|
|
||||||
|
## Artifacts and channels
|
||||||
|
|
||||||
|
Each immutable release contains:
|
||||||
|
|
||||||
|
- `release.json`, exact prod-pins revision, source lock identities, workflow run,
|
||||||
|
architectures, artifact sizes and SHA-256 hashes, plus `SHA256SUMS`.
|
||||||
|
- `iota-linux-ARCH`, `iota-daemon-linux-ARCH`, `iota-updater-linux-ARCH` and signed
|
||||||
|
`iota-update-linux-ARCH.json` with `.sig`, using Iota's typed Rust signer.
|
||||||
|
These executables are musl-static and run on ordinary Linux without Nix.
|
||||||
|
- `iota-portable-linux-ARCH.tar.gz`, per-user binaries under `bin` and static
|
||||||
|
assets under `share/iota/web`. Extract and run `./bin/iota`. The daemon and
|
||||||
|
updater are discovered next to the CLI. Host CA certificates supply TLS trust.
|
||||||
|
- `iota-linux-ARCH.zip`, checked by `iota-bundle`, with channel trust settings
|
||||||
|
and static assets, for system-wide installation through CLI bootstrap.
|
||||||
|
- `PACKAGE-linux-ARCH.nar.gz`, gzip-compressed Nix closure exports for Iota,
|
||||||
|
services, client, web and SDK. Restore with `gzip -dc FILE | nix-store --import`.
|
||||||
|
These are separate Nix artifacts, not the unmanaged Linux installation path.
|
||||||
|
- Docker-loadable Iota, Omikron and Omega images. Registry names are
|
||||||
|
`SERVER/tensamin/SERVICE:IMMUTABLE_TAG-ARCH`.
|
||||||
|
- Signed universal `Tensamin-IMMUTABLE_TAG.apk`, Linux Electron AppImage, deb and
|
||||||
|
rpm assets, and per-architecture Electron release metadata.
|
||||||
|
|
||||||
|
`stable` and `canary` are the only mutable metadata releases. Each has
|
||||||
|
`channel.json`, combined `electron-release-metadata.json`, and Iota update
|
||||||
|
manifests with signatures. Binary URLs always reference an immutable release.
|
||||||
|
Daily refresh re-signs manifests for the same binary identity with a higher
|
||||||
|
sequence and 14-day expiry. Publication and refresh share one concurrency group.
|
||||||
|
Sequences use Unix seconds, must strictly increase, and remain within Android's
|
||||||
|
version-code bound. Do not publish to these channels outside the serialized flow.
|
||||||
|
|
||||||
|
Forgejo attachment replacement is not transactional. On API failure the script
|
||||||
|
restores prior channel attachments and returns failure. Readers can encounter a
|
||||||
|
brief missing or mismatched manifest/signature pair and should retry. Old immutable
|
||||||
|
binary assets remain available. A failed stable promotion retains its draft for
|
||||||
|
inspection. Registry uploads use immutable tags and can leave unused images if a
|
||||||
|
later publication step fails.
|
||||||
|
|
||||||
|
The workflow tries aarch64 using configured Nix builders and execution support.
|
||||||
|
If that attempt fails, the x86_64 release includes `aarch64-unavailable.txt` and
|
||||||
|
`arm-build.log`. Partial aarch64 artifacts are not advertised. Full aarch64
|
||||||
|
Electron packaging requires execution support as well as a builder. Android is
|
||||||
|
built on x86_64 using prepared `client-source` and `mtp-sdk`, never the client's
|
||||||
|
original release SDK dependency. The client's tool shells use central inputs;
|
||||||
|
platform-tools is adjusted to the version available in central nixpkgs.
|
||||||
|
|
||||||
|
## Local validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop --impure --expr 'import ./scripts/release-env.nix { root = builtins.toPath (builtins.getEnv "PWD"); }' --command shellcheck scripts/release-build.sh scripts/release-client.sh
|
||||||
|
nix develop --impure --expr 'import ./scripts/release-env.nix { root = builtins.toPath (builtins.getEnv "PWD"); }' --command ruff check scripts/release.py scripts/deploy-release.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Live Forgejo draft uploads, registry writes and deployment are performed only by
|
||||||
|
the release workflow after builds and checks. Enabling stable requires the revised
|
||||||
|
infrastructure wrapper and all signing and deployment credentials above.
|
||||||
72
scripts/update-all.py
Normal file
72
scripts/update-all.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Refresh the shared sources, transformed locks, and fixed-output hashes."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
result = subprocess.run(args, text=True, **kwargs)
|
||||||
|
if result.returncode:
|
||||||
|
raise SystemExit(result.stderr if kwargs.get("capture_output") else result.returncode)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--no-update", action="store_true", help="Keep the current flake inputs")
|
||||||
|
parser.add_argument("--override-input", nargs=2, action="append", default=[], metavar=("NAME", "SOURCE"))
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = Path.cwd()
|
||||||
|
if not (root / "packages/hashes.nix").exists():
|
||||||
|
raise SystemExit("Run update-all from the prod-pins checkout")
|
||||||
|
if not args.no_update:
|
||||||
|
run("nix", "flake", "update", *(item for pair in args.override_input for item in ["--override-input", *pair]))
|
||||||
|
|
||||||
|
overrides = [item for pair in args.override_input for item in ["--override-input", *pair]]
|
||||||
|
|
||||||
|
system = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem", capture_output=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def build(name):
|
||||||
|
return run("nix", "build", f"path:{root}#packages.{system}.{name}",
|
||||||
|
"--no-link", "--print-out-paths", *overrides, capture_output=True).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
locks = root / "packages/locks"
|
||||||
|
locks.mkdir(exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="prod-pins-update-") as temporary:
|
||||||
|
for name in ["mtp", "iota", "omikron", "omega", "client"]:
|
||||||
|
source = build(f"{name}-source")
|
||||||
|
dest = Path(temporary) / name
|
||||||
|
shutil.copytree(source, dest, symlinks=True)
|
||||||
|
for path in [dest, *dest.rglob("*")]:
|
||||||
|
if not path.is_symlink():
|
||||||
|
path.chmod(path.stat().st_mode | 0o200)
|
||||||
|
if name == "client":
|
||||||
|
run("pnpm", "install", "--lockfile-only", "--ignore-scripts", "--no-frozen-lockfile", cwd=dest)
|
||||||
|
shutil.copyfile(dest / "pnpm-lock.yaml", locks / "client.yaml")
|
||||||
|
else:
|
||||||
|
run("cargo", "update", "--workspace", cwd=dest)
|
||||||
|
shutil.copyfile(dest / "Cargo.lock", locks / f"{name}.lock")
|
||||||
|
|
||||||
|
hash_file = root / "packages/hashes.nix"
|
||||||
|
for key, output in [(n, f"{n}-vendor") for n in ["mtp", "iota", "omikron", "omega"]] + [("sdk", "sdk-deps"), ("client", "client-deps")]:
|
||||||
|
# Force a fetch even when the previous hash points at a cached result.
|
||||||
|
text = hash_file.read_text()
|
||||||
|
original = text
|
||||||
|
text = re.sub(rf'({key} = ")[^"]+', rf'\g<1>sha256-{"A" * 43}=', text)
|
||||||
|
hash_file.write_text(text)
|
||||||
|
result = subprocess.run(["nix", "build", f"path:{root}#packages.{system}.{output}",
|
||||||
|
"--no-link", "--print-out-paths", *overrides], text=True, capture_output=True)
|
||||||
|
match = re.search(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", result.stderr)
|
||||||
|
if not match:
|
||||||
|
hash_file.write_text(original)
|
||||||
|
raise SystemExit(result.stderr or f"Could not determine {key} hash")
|
||||||
|
hash_file.write_text(re.sub(rf'({key} = ")[^"]+', rf'\g<1>{match[1]}', text))
|
||||||
|
build(output)
|
||||||
|
print(f"Updated {key}: {match[1]}", flush=True)
|
||||||
|
|
||||||
|
print("Updated sources, dependency locks, and verified vendor hashes.")
|
||||||
Loading…
Reference in a new issue