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 = {
|
||||
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: {
|
||||
packages = builtins.mapAttrs (system: pkgs: {
|
||||
hello = pkgs.hello;
|
||||
|
||||
default = inputs.self.packages.${system}.hello;
|
||||
}) inputs.nixpkgs.legacyPackages;
|
||||
outputs = inputs@{ self, nixpkgs, ... }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forSystems = nixpkgs.lib.genAttrs systems;
|
||||
project = system: import ./packages {
|
||||
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