Compare commits
26 Commits
5a0696ce64
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 557b6e9ef0 | |||
| e49f497045 | |||
| feb1a48db1 | |||
| 30732080b7 | |||
| 80cf1588bb | |||
| ef7d1b29f4 | |||
| 7e8c6884db | |||
| 1dd41e608b | |||
| e41c3e5a05 | |||
| f473280bf5 | |||
| b08fdd6e6b | |||
| f73bfc63be | |||
| f6e7c1eca9 | |||
| 6996d178ef | |||
| 7f7229b199 | |||
| 2d5bd26c36 | |||
| fba150b55b | |||
| 2e7bf58acf | |||
| b12c35f957 | |||
| bcf1b84dc4 | |||
| 7c25e3b46d | |||
| a20381e343 | |||
| bd92610a98 | |||
| e3ee881db6 | |||
| b9eabca464 | |||
| fcc72192f5 |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
flake,
|
||||
self,
|
||||
inputs,
|
||||
system ? "aarch64-darwin",
|
||||
...
|
||||
}: let
|
||||
name = builtins.baseNameOf ./.;
|
||||
in inputs.nix-darwin.lib.darwinSystem {
|
||||
inherit system;
|
||||
specialArgs = { inherit flake self inputs; };
|
||||
modules = [
|
||||
inputs.home-manager.darwinModules.home-manager
|
||||
{
|
||||
networking.hostName = name;
|
||||
nixpkgs.hostPlatform = system;
|
||||
nixpkgs.overlays = [ self.overlays.default ];
|
||||
}
|
||||
./${name}.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
flake,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}: let
|
||||
name = "yukkop";
|
||||
in {
|
||||
system.primaryUser = name;
|
||||
nix.settings.experimental-features = "nix-command flakes";
|
||||
|
||||
programs.zsh.enable = true;
|
||||
|
||||
services.openssh.enable = true;
|
||||
|
||||
users.users.${name} = {
|
||||
home = "/Users/${name}";
|
||||
openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJf9ljuqny71bZJokebK4Ybfml0MFMCkApS+tbMdBudp u0_a472@localhost"
|
||||
];
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
aerospace
|
||||
git
|
||||
moreutils
|
||||
neovim
|
||||
tmux
|
||||
];
|
||||
|
||||
launchd.user.agents.aerospace = {
|
||||
serviceConfig = {
|
||||
ProgramArguments = [
|
||||
"${pkgs.aerospace}/Applications/AeroSpace.app/Contents/MacOS/AeroSpace"
|
||||
];
|
||||
RunAtLoad = true;
|
||||
KeepAlive = true;
|
||||
StandardOutPath = "/tmp/aerospace.out.log";
|
||||
StandardErrorPath = "/tmp/aerospace.err.log";
|
||||
};
|
||||
};
|
||||
|
||||
home-manager.useGlobalPkgs = true;
|
||||
home-manager.useUserPackages = true;
|
||||
home-manager.backupFileExtension = "backup";
|
||||
home-manager.sharedModules = [
|
||||
(flake + "/home/module/program/tmux.nix")
|
||||
];
|
||||
home-manager.users.${name} = {
|
||||
home.stateVersion = "25.11";
|
||||
|
||||
home.packages = with pkgs; [
|
||||
iproute2mac
|
||||
jujutsu
|
||||
ripgrep
|
||||
];
|
||||
|
||||
programs.git = {
|
||||
enable = true;
|
||||
lfs.enable = true;
|
||||
settings = {
|
||||
user.name = name;
|
||||
user.email = "hectic.yukkop@gmail.com";
|
||||
push.autoSetupRemote = true;
|
||||
init.defaultBranch = "master";
|
||||
};
|
||||
};
|
||||
|
||||
programs.zsh = {
|
||||
enable = true;
|
||||
enableCompletion = true;
|
||||
autosuggestion.enable = true;
|
||||
syntaxHighlighting.enable = true;
|
||||
|
||||
history = {
|
||||
size = 10000;
|
||||
path = "$HOME/.zsh/.zsh_history";
|
||||
};
|
||||
|
||||
shellAliases = {
|
||||
drs = "darwin-rebuild switch --flake ~/pj/hearth#'yukkop|aarch64-darwin'";
|
||||
nv = "nvim";
|
||||
tmux = "tmux a";
|
||||
};
|
||||
|
||||
initContent = ''
|
||||
export PATH=/Users/yukkop/.opencode/bin:$PATH
|
||||
'';
|
||||
};
|
||||
|
||||
xdg.configFile."aerospace/aerospace.toml".text = ''
|
||||
start-at-login = false
|
||||
|
||||
enable-normalization-flatten-containers = true
|
||||
enable-normalization-opposite-orientation-for-nested-containers = true
|
||||
|
||||
default-root-container-layout = 'tiles'
|
||||
default-root-container-orientation = 'auto'
|
||||
accordion-padding = 30
|
||||
|
||||
on-focused-monitor-changed = ['move-mouse monitor-lazy-center']
|
||||
automatically-unhide-macos-hidden-apps = false
|
||||
|
||||
[exec]
|
||||
inherit-env-vars = true
|
||||
|
||||
[exec.env-vars]
|
||||
PATH = '/run/current-system/sw/bin:/etc/profiles/per-user/yukkop/bin:/nix/var/nix/profiles/default/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:''${PATH}'
|
||||
|
||||
[gaps]
|
||||
inner.horizontal = 8
|
||||
inner.vertical = 8
|
||||
outer.left = 8
|
||||
outer.bottom = 8
|
||||
outer.top = 8
|
||||
outer.right = 8
|
||||
|
||||
[mode.main.binding]
|
||||
alt-enter = 'exec-and-forget open -n /System/Applications/Utilities/Terminal.app'
|
||||
|
||||
alt-slash = 'layout tiles horizontal vertical'
|
||||
alt-comma = 'layout accordion horizontal vertical'
|
||||
alt-f = 'fullscreen'
|
||||
|
||||
alt-h = 'focus left'
|
||||
alt-j = 'focus down'
|
||||
alt-k = 'focus up'
|
||||
alt-l = 'focus right'
|
||||
|
||||
alt-shift-h = 'move left'
|
||||
alt-shift-j = 'move down'
|
||||
alt-shift-k = 'move up'
|
||||
alt-shift-l = 'move right'
|
||||
|
||||
alt-minus = 'resize smart -50'
|
||||
alt-equal = 'resize smart +50'
|
||||
|
||||
alt-1 = 'workspace 1'
|
||||
alt-2 = 'workspace 2'
|
||||
alt-3 = 'workspace 3'
|
||||
alt-4 = 'workspace 4'
|
||||
alt-5 = 'workspace 5'
|
||||
alt-6 = 'workspace 6'
|
||||
alt-7 = 'workspace 7'
|
||||
alt-8 = 'workspace 8'
|
||||
alt-9 = 'workspace 9'
|
||||
|
||||
alt-shift-1 = 'move-node-to-workspace 1'
|
||||
alt-shift-2 = 'move-node-to-workspace 2'
|
||||
alt-shift-3 = 'move-node-to-workspace 3'
|
||||
alt-shift-4 = 'move-node-to-workspace 4'
|
||||
alt-shift-5 = 'move-node-to-workspace 5'
|
||||
alt-shift-6 = 'move-node-to-workspace 6'
|
||||
alt-shift-7 = 'move-node-to-workspace 7'
|
||||
alt-shift-8 = 'move-node-to-workspace 8'
|
||||
alt-shift-9 = 'move-node-to-workspace 9'
|
||||
|
||||
alt-tab = 'workspace-back-and-forth'
|
||||
alt-shift-tab = 'move-workspace-to-monitor --wrap-around next'
|
||||
|
||||
alt-shift-semicolon = 'mode service'
|
||||
|
||||
[mode.service.binding]
|
||||
esc = ['reload-config', 'mode main']
|
||||
r = ['flatten-workspace-tree', 'mode main']
|
||||
f = ['layout floating tiling', 'mode main']
|
||||
b = ['balance-sizes', 'mode main']
|
||||
backspace = ['close-all-windows-but-current', 'mode main']
|
||||
|
||||
alt-shift-h = ['join-with left', 'mode main']
|
||||
alt-shift-j = ['join-with down', 'mode main']
|
||||
alt-shift-k = ['join-with up', 'mode main']
|
||||
alt-shift-l = ['join-with right', 'mode main']
|
||||
'';
|
||||
};
|
||||
system.stateVersion = 6;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
haskell = import ./haskell.nix { inherit self system pkgs; };
|
||||
neuro = import ./neuro.nix { inherit self system pkgs; };
|
||||
xmpp = import ./xmpp.nix { inherit self system pkgs; };
|
||||
gitea-runners = import ./gitea-runners.nix { inherit pkgs; };
|
||||
default = pkgs.mkShell {
|
||||
buildInputs =
|
||||
(with self.packages.${system}; [
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{ pkgs, ... }: let
|
||||
opentofuUnstable = "github:NixOS/nixpkgs/nixos-unstable#opentofu";
|
||||
|
||||
tofu = pkgs.writeShellScriptBin "tofu" ''
|
||||
exec ${pkgs.nix}/bin/nix run ${opentofuUnstable} -- "$@"
|
||||
'';
|
||||
|
||||
giteaRunnersSetup = pkgs.writeShellScriptBin "gitea-runners-setup" /* sh */ ''
|
||||
cat <<'EOF'
|
||||
Gitea runners setup checklist
|
||||
|
||||
Tools available in this shell:
|
||||
tofu, kubectl, kustomize, kubeconform, sops, age, awscli2, hcloud, tea,
|
||||
docker, skopeo, go-containerregistry, jq, yq-go, curl, git, openssh, nix
|
||||
|
||||
Environment expected before real deploy/apply:
|
||||
TF_VAR_hcloud_token
|
||||
TF_VAR_ssh_public_key
|
||||
TF_VAR_ssh_private_key
|
||||
S3 backend credentials and endpoint access
|
||||
a matching SOPS age identity for sus/gitea-runners.yaml
|
||||
kubectl access to the target cluster
|
||||
a concrete registry digest for the pushed Nix-capable runner image if enabling
|
||||
the nix label
|
||||
|
||||
OpenTofu validation gate:
|
||||
tofu version
|
||||
tofu -chdir=infra/gitea-runners/opentofu validate
|
||||
|
||||
Nix image build/publish/digest gate:
|
||||
nix build .#gitea-runner-nix-image
|
||||
publish the archive, then pin the registry-reported digest in the runner label
|
||||
mapping
|
||||
nix:docker://gitea.hectic-lab.com/hectic-lab/gitea-runner-nix-image@sha256:<registry-digest>
|
||||
|
||||
SOPS token Secret creation gate:
|
||||
kubectl apply -f infra/gitea-runners/k8s/namespace.yaml
|
||||
umask 077
|
||||
token_file=$(mktemp /tmp/gitea-runner-token.XXXXXX)
|
||||
trap 'rm -f "$token_file"' EXIT
|
||||
sops -d --extract '["gitea"]["hectic-lab"]["org-runner-registration-token"]' sus/gitea-runners.yaml > "$token_file"
|
||||
kubectl -n gitea-runners create secret generic gitea-runner-token \
|
||||
--from-file=token="$token_file" \
|
||||
--dry-run=client \
|
||||
-o yaml | kubectl -n gitea-runners apply -f -
|
||||
|
||||
Cluster provision gate:
|
||||
tofu -chdir=infra/gitea-runners/opentofu init
|
||||
tofu -chdir=infra/gitea-runners/opentofu validate
|
||||
tofu -chdir=infra/gitea-runners/opentofu plan -out=.sisyphus/evidence/task-12-deploy.plan
|
||||
tofu -chdir=infra/gitea-runners/opentofu apply .sisyphus/evidence/task-12-deploy.plan
|
||||
export KUBECONFIG="$(tofu -chdir=infra/gitea-runners/opentofu output -raw kubeconfig_path)"
|
||||
|
||||
Kubernetes apply gate:
|
||||
kubectl config current-context
|
||||
kubectl get nodes -o wide
|
||||
kubectl get sc
|
||||
kubectl apply -k infra/gitea-runners/k8s
|
||||
|
||||
Verification commands:
|
||||
kubectl -n gitea-runners get statefulset gitea-runner
|
||||
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
|
||||
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
|
||||
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
|
||||
kubectl -n gitea-runners get events --sort-by=.lastTimestamp | tail -n 50
|
||||
kubectl -n gitea-runners logs statefulset/gitea-runner -c runner --tail=200
|
||||
|
||||
Main blockers and gates:
|
||||
do not run tofu apply without all external inputs
|
||||
do not apply the k8s overlay until the gitea-runner-token Secret exists
|
||||
do not enable the nix label until the image has been published with a concrete digest
|
||||
do not print, load, or require secrets on shell entry
|
||||
EOF
|
||||
'';
|
||||
in pkgs.mkShell {
|
||||
name = "gitea-runners";
|
||||
|
||||
buildInputs = [
|
||||
tofu
|
||||
giteaRunnersSetup
|
||||
pkgs.nix
|
||||
pkgs.kubectl
|
||||
pkgs.kustomize
|
||||
pkgs.kubeconform
|
||||
pkgs.sops
|
||||
pkgs.age
|
||||
pkgs.awscli2
|
||||
pkgs.hcloud
|
||||
pkgs.tea
|
||||
pkgs.docker
|
||||
pkgs.skopeo
|
||||
pkgs.go-containerregistry
|
||||
pkgs.jq
|
||||
pkgs.yq-go
|
||||
pkgs.curl
|
||||
pkgs.git
|
||||
pkgs.openssh
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export GITEA_RUNNERS_ROOT="$PWD/infra/gitea-runners"
|
||||
export GITEA_RUNNERS_TOFU_DIR="$GITEA_RUNNERS_ROOT/opentofu"
|
||||
export GITEA_RUNNERS_K8S_DIR="$GITEA_RUNNERS_ROOT/k8s"
|
||||
export GITEA_RUNNERS_IMAGE_DIR="$GITEA_RUNNERS_ROOT/image"
|
||||
export GITEA_RUNNERS_NAMESPACE="gitea-runners"
|
||||
|
||||
alias cd-gitea-runners='cd "$GITEA_RUNNERS_ROOT"'
|
||||
alias cd-gitea-runners-tofu='cd "$GITEA_RUNNERS_TOFU_DIR"'
|
||||
alias cd-gitea-runners-k8s='cd "$GITEA_RUNNERS_K8S_DIR"'
|
||||
|
||||
echo ""
|
||||
echo "=== Gitea runner setup DevShell ==="
|
||||
echo ""
|
||||
echo "Run gitea-runners-setup for the full setup checklist."
|
||||
echo "Paths: "
|
||||
echo " root=$GITEA_RUNNERS_ROOT"
|
||||
echo " tofu=$GITEA_RUNNERS_TOFU_DIR"
|
||||
echo " k8s=$GITEA_RUNNERS_K8S_DIR"
|
||||
echo " image=$GITEA_RUNNERS_IMAGE_DIR"
|
||||
echo ""
|
||||
'';
|
||||
}
|
||||
Generated
+22
@@ -688,6 +688,27 @@
|
||||
"url": "ssh://git@github.com/LysmiMx/mechabellum-replay-analysis.git"
|
||||
}
|
||||
},
|
||||
"nix-darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772129556,
|
||||
"narHash": "sha256-Utk0zd8STPsUJPyjabhzPc5BpPodLTXrwkpXBHYnpeg=",
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "ebec37af18215214173c98cf6356d0aca24a2585",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-darwin",
|
||||
"ref": "nix-darwin-25.11",
|
||||
"repo": "nix-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-minecraft": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat_3",
|
||||
@@ -967,6 +988,7 @@
|
||||
"hyprland": "hyprland",
|
||||
"impermanence": "impermanence",
|
||||
"mechabellum-replay-analysis": "mechabellum-replay-analysis",
|
||||
"nix-darwin": "nix-darwin",
|
||||
"nix-minecraft": "nix-minecraft",
|
||||
"nixos-anywhere": "nixos-anywhere",
|
||||
"nixos-hardware": "nixos-hardware",
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
url = "github:nix-community/home-manager/release-25.11";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nix-darwin = {
|
||||
url = "github:nix-darwin/nix-darwin/nix-darwin-25.11";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nixos-wsl = {
|
||||
url = "github:nix-community/NixOS-WSL";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -120,7 +124,11 @@
|
||||
"neuro|x86_64-linux" = import ./nixos/system/neuro { inherit flake self inputs; system = "x86_64-linux"; };
|
||||
"games|x86_64-linux" = import ./nixos/system/games { inherit flake self inputs; system = "x86_64-linux"; };
|
||||
"wsl|x86_64-linux" = import ./nixos/system/wsl { inherit flake self inputs; system = "x86_64-linux"; };
|
||||
"tenix|x86_64-linux" = import ./nixos/system/tenix { inherit flake self inputs; system = "x86_64-linux"; };
|
||||
"hectic-lab|x86_64-linux" = import ./nixos/system/hectic-lab { inherit flake self inputs; system = "x86_64-linux"; };
|
||||
};
|
||||
darwinConfigurations = {
|
||||
"yukkop|aarch64-darwin" = import ./darwin/system/yukkop { inherit flake self inputs; system = "aarch64-darwin"; };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{ pkgs, ... }: {
|
||||
programs.tmux = {
|
||||
enable = true;
|
||||
plugins = with pkgs.tmuxPlugins; [ resurrect continuum ];
|
||||
keyMode = "vi";
|
||||
escapeTime = 500;
|
||||
historyLimit = 50000;
|
||||
newSession = true;
|
||||
extraConfig = ''
|
||||
# resurrect
|
||||
set -g @resurrect-strategy-vim 'session'
|
||||
set -g @resurrect-strategy-nvim 'session'
|
||||
set -g @resurrect-capture-pane-contents 'on'
|
||||
|
||||
resurrect_dir="$HOME/.tmux/resurrect"
|
||||
set -g @resurrect-dir $resurrect_dir
|
||||
set -g @resurrect-hook-post-save-all 'target=$(readlink -f $resurrect_dir/last); sed "s| --cmd .*-vim-pack-dir||g; s|/etc/profiles/per-user/$USER/bin/||g; s|/home/$USER/.nix-profile/bin/||g" $target | sponge $target'
|
||||
|
||||
# continuum
|
||||
set -g @continuum-restore 'on'
|
||||
set -g @continuum-boot 'on'
|
||||
set -g @continuum-save-interval '10'
|
||||
|
||||
bind-key -T copy-mode-vi v send-keys -X begin-selection
|
||||
bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
|
||||
|
||||
bind-key O select-pane -t :.-
|
||||
'';
|
||||
};
|
||||
}
|
||||
+30
-2
@@ -26,12 +26,39 @@
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
cudaUnfreeNames = [
|
||||
"cuda_nvcc"
|
||||
"cuda_cudart"
|
||||
"cuda_cuobjdump"
|
||||
"cuda_cupti"
|
||||
"cuda_nvdisasm"
|
||||
"cuda_cccl"
|
||||
"cuda_nvml_dev"
|
||||
"cuda_nvrtc"
|
||||
"cuda_nvtx"
|
||||
"cuda_profiler_api"
|
||||
|
||||
"libcusparse_lt"
|
||||
"libcublas"
|
||||
"libcufft"
|
||||
"libcufile"
|
||||
"libcurand"
|
||||
"libcusolver"
|
||||
"libnvjitlink"
|
||||
"libcusparse"
|
||||
"cudnn"
|
||||
];
|
||||
|
||||
cudaUnfreePredicate = pkg:
|
||||
builtins.elem (nixpkgs.lib.getName pkg) cudaUnfreeNames;
|
||||
|
||||
forSystemsWithPkgs = supportedSystems: pkgOverlays: f:
|
||||
builtins.foldl' (
|
||||
acc: system: let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = pkgOverlays;
|
||||
config.allowUnfreePredicate = cudaUnfreePredicate;
|
||||
};
|
||||
systemOutputs = f {
|
||||
system = system;
|
||||
@@ -58,7 +85,7 @@
|
||||
else {};
|
||||
in {
|
||||
# -- For all systems --
|
||||
inherit dotEnv minorEnvironment parseEnv forAllSystemsWithPkgs forSystemsWithPkgs commonSystems AllSystems;
|
||||
inherit dotEnv minorEnvironment parseEnv forAllSystemsWithPkgs forSystemsWithPkgs commonSystems AllSystems cudaUnfreeNames cudaUnfreePredicate;
|
||||
|
||||
forSystems = systems: nixpkgs.lib.genAttrs systems;
|
||||
forAllSystems = nixpkgs.lib.genAttrs AllSystems;
|
||||
@@ -67,6 +94,7 @@ in {
|
||||
logs = builtins.readFile ./shell/logs.sh;
|
||||
check-tool = builtins.readFile ./shell/check-tool.sh;
|
||||
local-dir = builtins.readFile ./shell/local-dir.sh;
|
||||
load-sops = builtins.readFile ./shell/load-sops.sh;
|
||||
};
|
||||
|
||||
sharedShellAliases = {
|
||||
@@ -102,7 +130,7 @@ in {
|
||||
|
||||
# Consolidated SQL bundles for the `hectic` schema. Single source of truth
|
||||
# for everything that creates objects in the `hectic` namespace, used by
|
||||
# migrator (init-time) and db-tool (postgres-init + hydrate). Consumers apply
|
||||
# migrator (init-time), db-dev/database hydrate, and db-ops secrets loading. Consumers apply
|
||||
# the full bundle via lib/hook/apply-hectic-bundle.sh.
|
||||
#
|
||||
# The whole hectic system shares one `versionString`; `hectic-version.sql`
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
# Usage:
|
||||
# apply_hectic_bundle <PGURL> [<DOTENV_CONTENT>]
|
||||
#
|
||||
# If DOTENV_CONTENT is non-empty, it is loaded into hectic.secret via
|
||||
# hectic.load_secrets_from_env() after the bundle is applied.
|
||||
# If DOTENV_CONTENT is non-empty, it is base64-encoded and then loaded into
|
||||
# hectic.secret via hectic.load_secrets_from_env() after the bundle is applied.
|
||||
# SQL file paths are substituted by Nix evaluation time.
|
||||
|
||||
apply_hectic_bundle() {
|
||||
@@ -41,11 +41,9 @@ apply_hectic_bundle() {
|
||||
done
|
||||
|
||||
if [ -n "$env_content" ]; then
|
||||
# Dollar-quote with $ps_env$ tag to preserve all content verbatim.
|
||||
env_content_b64="$(printf '%s' "$env_content" | base64 | tr -d '\n')" || return 1
|
||||
psql "$pgurl" -v ON_ERROR_STOP=1 <<SQL || return 1
|
||||
SELECT hectic.load_secrets_from_env(\$ps_env\$
|
||||
$env_content
|
||||
\$ps_env\$);
|
||||
SELECT hectic.load_secrets_from_env(convert_from(decode('$env_content_b64', 'base64'), 'UTF8'));
|
||||
SQL
|
||||
fi
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ Single source of truth for every object created in the `hectic` PostgreSQL
|
||||
schema. Consumed by:
|
||||
|
||||
- `package/migrator` — applies the bundle on `migrator init` (mandatory).
|
||||
- `package/db-tool` — applies the bundle in `database hydrate` (default; opt
|
||||
out with `--no-hook`).
|
||||
- `package/db-tool` — applies the bundle in `db-dev` / `database hydrate`
|
||||
(default; opt out with `--no-hook`) and in `db-ops secrets load`.
|
||||
- External consumers (e.g. `proxydoe`) — invoke `psql -f` directly against the
|
||||
paths exposed via `self.lib.hectic.*.path`.
|
||||
|
||||
@@ -60,7 +60,7 @@ verbatim source files in the store.
|
||||
|
||||
`lib/hook/apply-hectic-bundle.sh` is a dash-compatible helper template.
|
||||
`self.lib.hectic.applyBundleScript` is the generated shell source with concrete
|
||||
SQL paths embedded at Nix evaluation time. `migrator` and `db-tool` splice that
|
||||
SQL paths embedded at Nix evaluation time. `migrator`, `db-dev`, and `db-ops` splice that
|
||||
shell source directly into their generated scripts. Public entry point:
|
||||
|
||||
```sh
|
||||
@@ -87,8 +87,9 @@ External consumers that do not want to source the helper can still invoke
|
||||
4. Add a matching placeholder/replacement in `lib.hectic.applyBundleScript` and
|
||||
update `lib/hook/apply-hectic-bundle.sh` to apply the file.
|
||||
5. Bump `HECTIC_VERSION` if the new content changes existing semantics.
|
||||
6. Update tests in `test/package/migrator/test/postgresql/init-hectic-bundle/`
|
||||
and `test/package/db-tool/test/postgresql/hydrate-hook/`.
|
||||
6. Update tests in `test/package/migrator/test/postgresql/init-hectic-bundle/`,
|
||||
`test/package/db-tool/test/postgresql/hydrate-hook/`, and any `db-ops`
|
||||
bundle-loading coverage.
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
load_sops_shell_quote() {
|
||||
printf "'"
|
||||
printf '%s' "$1" | sed "s/'/'\"'\"'/g"
|
||||
printf "'"
|
||||
}
|
||||
|
||||
load_sops_normalize_key() {
|
||||
load_sops_normalized_key=$(printf '%s' "$1" | tr '.-' '__' | tr '[:lower:]' '[:upper:]')
|
||||
|
||||
case "$load_sops_normalized_key" in
|
||||
''|[!A-Z_]*|*[!A-Z0-9_]*)
|
||||
printf 'load-sops: invalid environment name after key normalization\n' >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$load_sops_normalized_key"
|
||||
}
|
||||
|
||||
load_sops_env_from_sops_file() {
|
||||
load_sops_file=$1
|
||||
load_sops_extract=${2-}
|
||||
|
||||
if ! command -v sops >/dev/null 2>&1; then
|
||||
printf 'load-sops: required tool `sops` not found\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v yq >/dev/null 2>&1; then
|
||||
printf 'load-sops: required tool `yq` not found\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_sops_decrypted=''
|
||||
load_sops_attempt=0
|
||||
load_sops_max_retries=${LOAD_SOPS_MAX_RETRIES:-1}
|
||||
load_sops_prompt=${LOAD_SOPS_PROMPT:-0}
|
||||
|
||||
while :; do
|
||||
if [ -n "$load_sops_extract" ]; then
|
||||
if load_sops_decrypted=$(sops -d --extract "$load_sops_extract" "$load_sops_file" 2>/dev/null); then
|
||||
load_sops_status=0
|
||||
else
|
||||
load_sops_status=$?
|
||||
fi
|
||||
else
|
||||
if load_sops_decrypted=$(sops -d "$load_sops_file" 2>/dev/null); then
|
||||
load_sops_status=0
|
||||
else
|
||||
load_sops_status=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$load_sops_status" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$load_sops_prompt" != 1 ]; then
|
||||
printf 'load-sops: failed to decrypt file\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! [ -t 0 ] || ! [ -r /dev/tty ]; then
|
||||
printf 'load-sops: decrypt failed and prompt requested, but no TTY is available\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_sops_attempt=$((load_sops_attempt + 1))
|
||||
if [ "$load_sops_max_retries" != 0 ] && [ "$load_sops_attempt" -gt "$load_sops_max_retries" ]; then
|
||||
printf 'load-sops: decrypt failed after configured retries\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_sops_use_script=${LOAD_SOPS_USE_SCRIPT:-auto}
|
||||
load_sops_quoted_file=$(load_sops_shell_quote "$load_sops_file") || return 1
|
||||
load_sops_quoted_tty=$(load_sops_shell_quote "$(tty)") || return 1
|
||||
case "$load_sops_use_script" in
|
||||
auto)
|
||||
if command -v script >/dev/null 2>&1 && [ -t 0 ]; then
|
||||
if script -qefc "env GPG_TTY=$load_sops_quoted_tty sops --output /dev/null -d $load_sops_quoted_file" /dev/null >/dev/null 2>&1; then
|
||||
load_sops_script_status=0
|
||||
else
|
||||
load_sops_script_status=$?
|
||||
fi
|
||||
if [ "$load_sops_script_status" -eq 0 ]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
1)
|
||||
if ! command -v script >/dev/null 2>&1; then
|
||||
printf 'load-sops: required tool `script` not found\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if script -qefc "env GPG_TTY=$load_sops_quoted_tty sops --output /dev/null -d $load_sops_quoted_file" /dev/null >/dev/null 2>&1; then
|
||||
load_sops_script_status=0
|
||||
else
|
||||
load_sops_script_status=$?
|
||||
fi
|
||||
if [ "$load_sops_script_status" -eq 0 ]; then
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
0)
|
||||
;;
|
||||
*)
|
||||
printf 'load-sops: LOAD_SOPS_USE_SCRIPT must be auto, 0, or 1\n' >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'load-sops: enter SOPS_AGE_KEY_CMD: ' >/dev/tty
|
||||
if ! IFS= read -r SOPS_AGE_KEY_CMD </dev/tty; then
|
||||
printf 'load-sops: failed to read prompt input\n' >&2
|
||||
return 1
|
||||
fi
|
||||
export SOPS_AGE_KEY_CMD
|
||||
done
|
||||
|
||||
load_sops_env_from_yaml_text "$load_sops_decrypted"
|
||||
}
|
||||
|
||||
load_sops_env_from_yaml_file() {
|
||||
load_sops_file=$1
|
||||
|
||||
if ! command -v yq >/dev/null 2>&1; then
|
||||
printf 'load-sops: required tool `yq` not found\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_sops_env_from_yaml_text "$(cat "$load_sops_file")"
|
||||
}
|
||||
|
||||
load_sops_env_from_yaml_text() {
|
||||
load_sops_yaml=$1
|
||||
load_sops_seen=''
|
||||
|
||||
if load_sops_keys=$(printf '%s' "$load_sops_yaml" | yq -r 'keys | .[]' 2>/dev/null); then
|
||||
load_sops_keys_status=0
|
||||
else
|
||||
load_sops_keys_status=$?
|
||||
fi
|
||||
|
||||
if [ "$load_sops_keys_status" -ne 0 ]; then
|
||||
printf 'load-sops: failed to inspect YAML top-level keys\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
while IFS= read -r load_sops_key; do
|
||||
[ -n "$load_sops_key" ] || continue
|
||||
|
||||
load_sops_name=$(load_sops_normalize_key "$load_sops_key") || return 1
|
||||
|
||||
case "
|
||||
$load_sops_seen
|
||||
" in
|
||||
*"
|
||||
$load_sops_name
|
||||
"*)
|
||||
if [ "${LOAD_SOPS_ALLOW_COLLISIONS:-0}" != 1 ]; then
|
||||
printf 'load-sops: normalized environment name collision\n' >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
load_sops_seen=${load_sops_seen}${load_sops_seen:+"
|
||||
"}$load_sops_name
|
||||
|
||||
load_sops_kind=$(printf '%s' "$load_sops_yaml" | yq -r '."'"$load_sops_key"'" | kind' 2>/dev/null) || {
|
||||
printf 'load-sops: failed to inspect YAML value kind\n' >&2
|
||||
return 1
|
||||
}
|
||||
load_sops_tag=$(printf '%s' "$load_sops_yaml" | yq -r '."'"$load_sops_key"'" | tag' 2>/dev/null) || {
|
||||
printf 'load-sops: failed to inspect YAML value tag\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$load_sops_kind" != scalar ]; then
|
||||
printf 'load-sops: top-level YAML values must be scalars\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$load_sops_tag" = '!!null' ]; then
|
||||
printf 'load-sops: top-level YAML null values are not supported\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "${LOAD_SOPS_OVERWRITE:-1}" = 0 ]; then
|
||||
eval 'load_sops_already_set=${'"$load_sops_name"'+x}'
|
||||
if [ -n "$load_sops_already_set" ]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
load_sops_value=$(printf '%s' "$load_sops_yaml" | yq -r '."'"$load_sops_key"'"' 2>/dev/null) || {
|
||||
printf 'load-sops: failed to read YAML scalar value\n' >&2
|
||||
return 1
|
||||
}
|
||||
load_sops_quoted=$(load_sops_shell_quote "$load_sops_value") || return 1
|
||||
eval "export $load_sops_name=$load_sops_quoted"
|
||||
done <<EOF
|
||||
$load_sops_keys
|
||||
EOF
|
||||
}
|
||||
@@ -250,6 +250,7 @@ in {
|
||||
# failover flip does not need a separate provisioning step.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/matrix-synapse 0750 matrix-synapse matrix-synapse -"
|
||||
"Z ${s3Cfg.mediaStorePath} 0700 matrix-synapse matrix-synapse -"
|
||||
];
|
||||
|
||||
systemd.services.matrix-cluster-signing-key = {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
inputs,
|
||||
flake,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
{
|
||||
pkgs,
|
||||
@@ -11,6 +9,13 @@
|
||||
}: let
|
||||
cfg = config.hectic.hardware.hetzner-cloud;
|
||||
isNewer = cfg.generation == "newer";
|
||||
networkMatchConfig =
|
||||
(lib.optionalAttrs (cfg.networkMatchConfigName != null) {
|
||||
Name = cfg.networkMatchConfigName;
|
||||
})
|
||||
// (lib.optionalAttrs (cfg.networkMatchConfigMac != null) {
|
||||
PermanentMACAddress = cfg.networkMatchConfigMac;
|
||||
});
|
||||
in {
|
||||
options.hectic.hardware.hetzner-cloud = {
|
||||
enable = lib.mkEnableOption "Enable hetzner-cloud hardware configurations";
|
||||
@@ -51,6 +56,14 @@ in {
|
||||
|
||||
'';
|
||||
};
|
||||
floatingIpv4 = lib.mkOption {
|
||||
type = with lib.types; nullOr (strMatching "^([0-9]{1,3}\\.){3}[0-9]{1,3}$");
|
||||
default = null;
|
||||
example = "188.243.124.247";
|
||||
description = ''
|
||||
Optional Hetzner Floating IPv4 configured as `/32` on the primary interface.
|
||||
'';
|
||||
};
|
||||
device = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = if isNewer then "/dev/nvme0n1" else "/dev/sda";
|
||||
@@ -65,14 +78,27 @@ in {
|
||||
'';
|
||||
};
|
||||
networkMatchConfigName = lib.mkOption {
|
||||
type = lib.types.strMatching "^(enp1s0|ens3|eth0)$";
|
||||
type = with lib.types; nullOr str;
|
||||
default = null;
|
||||
example = "enp1s0";
|
||||
description = ''
|
||||
type of network conection,
|
||||
on older hetzner servers may be `ens3` or else
|
||||
on newer probably `enp1s0`
|
||||
Optional interface name to match in systemd-networkd.
|
||||
|
||||
you can use `networkctl list` on server to know it
|
||||
Prefer `networkMatchConfigMac` for stable matching across rescue
|
||||
images and installed systems that may rename interfaces differently.
|
||||
|
||||
You can use `networkctl list` on server to know it.
|
||||
'';
|
||||
};
|
||||
networkMatchConfigMac = lib.mkOption {
|
||||
type = with lib.types; nullOr (strMatching "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$");
|
||||
default = null;
|
||||
example = "92:00:08:4a:b0:32";
|
||||
description = ''
|
||||
Optional permanent MAC address to match in systemd-networkd.
|
||||
|
||||
This is the preferred Hetzner Cloud matching method because interface
|
||||
names can differ between rescue images and installed NixOS systems.
|
||||
'';
|
||||
};
|
||||
};
|
||||
@@ -80,6 +106,15 @@ in {
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge
|
||||
[
|
||||
{
|
||||
boot.loader.systemd-boot.enable = false;
|
||||
boot.loader.efi.canTouchEfiVariables = false;
|
||||
boot.loader.grub = {
|
||||
enable = true;
|
||||
efiSupport = true;
|
||||
efiInstallAsRemovable = true;
|
||||
device = "nodev";
|
||||
};
|
||||
|
||||
boot.initrd.availableKernelModules = [
|
||||
"ata_piix"
|
||||
"uhci_hcd"
|
||||
@@ -90,12 +125,12 @@ in {
|
||||
networking.useNetworkd = true;
|
||||
systemd.network.enable = true;
|
||||
systemd.network.networks."30-wan" = {
|
||||
matchConfig.Name = cfg.networkMatchConfigName;
|
||||
matchConfig = networkMatchConfig;
|
||||
networkConfig.DHCP = "no";
|
||||
address = [
|
||||
"${cfg.ipv4}/32"
|
||||
"${cfg.ipv6}::/64"
|
||||
];
|
||||
] ++ lib.optional (cfg.floatingIpv4 != null) "${cfg.floatingIpv4}/32";
|
||||
routes = [
|
||||
{ Gateway = "172.31.1.1"; GatewayOnLink = true; }
|
||||
{ Gateway = "fe80::1"; }
|
||||
@@ -137,6 +172,13 @@ in {
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.networkMatchConfigName != null || cfg.networkMatchConfigMac != null;
|
||||
message = "hectic.hardware.hetzner-cloud requires networkMatchConfigName or networkMatchConfigMac";
|
||||
}
|
||||
];
|
||||
}
|
||||
(lib.mkIf (pkgs.stdenv.hostPlatform.system == "aarch64-linux") {
|
||||
boot.initrd.kernelModules = [ "virtio_gpu" ];
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{ ... }:
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}: let
|
||||
cfg = config.hectic.hardware.njalla;
|
||||
in {
|
||||
options.hectic.hardware.njalla = {
|
||||
enable = lib.mkEnableOption "Enable njalla hardware configurations";
|
||||
ipv4 = lib.mkOption {
|
||||
type = lib.types.strMatching "^([0-9]{1,3}\\.){3}[0-9]{1,3}$";
|
||||
example = "185.193.126.103";
|
||||
description = ''
|
||||
Njalla IPv4 address assigned to the host.
|
||||
'';
|
||||
};
|
||||
ipv4PrefixLength = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 24;
|
||||
example = 24;
|
||||
description = ''
|
||||
Njalla IPv4 prefix length.
|
||||
'';
|
||||
};
|
||||
ipv4Gateway = lib.mkOption {
|
||||
type = lib.types.strMatching "^([0-9]{1,3}\\.){3}[0-9]{1,3}$";
|
||||
default = "185.193.126.1";
|
||||
example = "185.193.126.1";
|
||||
description = ''
|
||||
Njalla IPv4 gateway.
|
||||
'';
|
||||
};
|
||||
ipv6 = lib.mkOption {
|
||||
type = lib.types.strMatching "^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$";
|
||||
example = "2a0a:3840:1337:126:0:b9c1:7e67:1337";
|
||||
description = ''
|
||||
Njalla IPv6 address assigned to the host.
|
||||
'';
|
||||
};
|
||||
ipv6PrefixLength = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 64;
|
||||
example = 64;
|
||||
description = ''
|
||||
Njalla IPv6 prefix length.
|
||||
'';
|
||||
};
|
||||
ipv6Gateway = lib.mkOption {
|
||||
type = lib.types.strMatching "^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$";
|
||||
default = "2a0a:3840:1337:126::1";
|
||||
example = "2a0a:3840:1337:126::1";
|
||||
description = ''
|
||||
Njalla IPv6 gateway.
|
||||
'';
|
||||
};
|
||||
networkMatchConfigName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "eth0";
|
||||
example = "eth0";
|
||||
description = ''
|
||||
Njalla container network interface name.
|
||||
'';
|
||||
};
|
||||
device = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/dev/vda";
|
||||
example = "/dev/disk/by-id/virtio-root";
|
||||
description = ''
|
||||
Njalla installation disk for disko/nixos-anywhere.
|
||||
|
||||
`/dev/vda` is the default block device visible on the inspected Njalla
|
||||
host. Prefer a stable `/dev/disk/by-id/...` path when available.
|
||||
'';
|
||||
};
|
||||
enableDisko = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Whether to provide a disko layout for nixos-anywhere installs.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||
{
|
||||
boot.isContainer = true;
|
||||
|
||||
networking.useDHCP = false;
|
||||
networking.useNetworkd = true;
|
||||
systemd.network.enable = true;
|
||||
systemd.network.networks."30-wan" = {
|
||||
matchConfig.Name = cfg.networkMatchConfigName;
|
||||
networkConfig.DHCP = "no";
|
||||
address = [
|
||||
"${cfg.ipv4}/${toString cfg.ipv4PrefixLength}"
|
||||
"${cfg.ipv6}/${toString cfg.ipv6PrefixLength}"
|
||||
];
|
||||
routes = [
|
||||
{ Gateway = cfg.ipv4Gateway; }
|
||||
{ Gateway = cfg.ipv6Gateway; }
|
||||
];
|
||||
};
|
||||
|
||||
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
|
||||
}
|
||||
(lib.mkIf cfg.enableDisko {
|
||||
boot.loader.grub.device = cfg.device;
|
||||
|
||||
disko.devices.disk.main = {
|
||||
type = "disk";
|
||||
device = cfg.device;
|
||||
content = {
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
boot = {
|
||||
size = "1M";
|
||||
type = "EF02";
|
||||
priority = 1;
|
||||
};
|
||||
root = {
|
||||
size = "100%";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -25,36 +25,7 @@ in {
|
||||
programs.bash.shellAliases.tmux = "tmux a";
|
||||
|
||||
home-manager.sharedModules = [
|
||||
{
|
||||
programs.tmux = {
|
||||
enable = true;
|
||||
plugins = with pkgs.tmuxPlugins; [ resurrect continuum ];
|
||||
keyMode = "vi";
|
||||
escapeTime = 500;
|
||||
historyLimit = 50000;
|
||||
newSession = true;
|
||||
extraConfig = ''
|
||||
# resurrect
|
||||
set -g @resurrect-strategy-vim 'session'
|
||||
set -g @resurrect-strategy-nvim 'session'
|
||||
set -g @resurrect-capture-pane-contents 'on'
|
||||
|
||||
resurrect_dir="$HOME/.tmux/resurrect"
|
||||
set -g @resurrect-dir $resurrect_dir
|
||||
set -g @resurrect-hook-post-save-all 'target=$(readlink -f $resurrect_dir/last); sed "s| --cmd .*-vim-pack-dir||g; s|/etc/profiles/per-user/$USER/bin/||g; s|/home/$USER/.nix-profile/bin/||g" $target | sponge $target'
|
||||
|
||||
# continuum
|
||||
set -g @continuum-restore 'on'
|
||||
set -g @continuum-boot 'on'
|
||||
set -g @continuum-save-interval '10'
|
||||
|
||||
bind-key -T copy-mode-vi v send-keys -X begin-selection
|
||||
bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
|
||||
|
||||
bind-key O select-pane -t :.-
|
||||
'';
|
||||
};
|
||||
}
|
||||
(flake + "/home/module/program/tmux.nix")
|
||||
];
|
||||
|
||||
home-manager.users.root.home.stateVersion = lib.mkDefault "25.05";
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
{ ... }: {
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}: let
|
||||
cfg = config.hectic.services.stable-video-diffusion;
|
||||
in {
|
||||
options.hectic.services.stable-video-diffusion = {
|
||||
enable = lib.mkEnableOption "local Stable Video Diffusion HTTP API";
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address the Stable Video Diffusion API binds to.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 7861;
|
||||
description = "Port the Stable Video Diffusion API binds to.";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.hectic.stable-video-diffusion-api;
|
||||
defaultText = lib.literalExpression "pkgs.hectic.stable-video-diffusion-api";
|
||||
description = "Package providing the Stable Video Diffusion API executable.";
|
||||
};
|
||||
|
||||
modelId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "stabilityai/stable-video-diffusion-img2vid-xt";
|
||||
description = "Model identifier loaded by the Stable Video Diffusion API.";
|
||||
};
|
||||
|
||||
device = lib.mkOption {
|
||||
type = with lib.types; nullOr (enum [ "cpu" "cuda" ]);
|
||||
default = null;
|
||||
description = ''
|
||||
Torch device requested from the Stable Video Diffusion API. When null,
|
||||
the package keeps its own auto-detection behavior.
|
||||
'';
|
||||
};
|
||||
|
||||
libraryPath = lib.mkOption {
|
||||
type = with lib.types; listOf str;
|
||||
default = [];
|
||||
description = ''
|
||||
Runtime library paths added to LD_LIBRARY_PATH. CUDA/NVIDIA deployments
|
||||
should include /run/opengl-driver/lib so libcuda.so is visible to torch.
|
||||
'';
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/stable-video-diffusion-api";
|
||||
description = "Persistent state directory for the Stable Video Diffusion API.";
|
||||
};
|
||||
|
||||
cacheDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/cache/stable-video-diffusion-api";
|
||||
description = "Cache directory for downloaded model and runtime artifacts.";
|
||||
};
|
||||
|
||||
outputDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${cfg.stateDir}/outputs";
|
||||
defaultText = lib.literalExpression ''"\${config.hectic.services.stable-video-diffusion.stateDir}/outputs"'';
|
||||
description = "Directory where generated video outputs are written.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = with lib.types; nullOr path;
|
||||
default = null;
|
||||
description = ''
|
||||
Optional environment file for secrets or runtime overrides. Values from
|
||||
the unit environment define SVD_API_HOST, SVD_API_PORT, SVD_MODEL_ID,
|
||||
SVD_STATE_DIR, SVD_CACHE_DIR, SVD_OUTPUT_DIR, and optionally SVD_DEVICE
|
||||
and LD_LIBRARY_PATH by default.
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the API port in the firewall.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.device != "cuda" || cfg.libraryPath != [];
|
||||
message = "hectic.services.stable-video-diffusion.libraryPath must include the NVIDIA runtime library path when device is cuda.";
|
||||
}
|
||||
];
|
||||
|
||||
users.users.stable-video-diffusion = {
|
||||
isSystemUser = true;
|
||||
group = "stable-video-diffusion";
|
||||
};
|
||||
users.groups.stable-video-diffusion = {};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 stable-video-diffusion stable-video-diffusion -"
|
||||
"d ${cfg.cacheDir} 0750 stable-video-diffusion stable-video-diffusion -"
|
||||
"d ${cfg.outputDir} 0750 stable-video-diffusion stable-video-diffusion -"
|
||||
];
|
||||
|
||||
systemd.services.stable-video-diffusion-api = {
|
||||
description = "Stable Video Diffusion HTTP API";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
environment = {
|
||||
SVD_API_HOST = cfg.host;
|
||||
SVD_API_PORT = toString cfg.port;
|
||||
SVD_MODEL_ID = cfg.modelId;
|
||||
SVD_STATE_DIR = cfg.stateDir;
|
||||
SVD_CACHE_DIR = cfg.cacheDir;
|
||||
SVD_OUTPUT_DIR = cfg.outputDir;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.device != null) {
|
||||
SVD_DEVICE = cfg.device;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.libraryPath != []) {
|
||||
LD_LIBRARY_PATH = lib.concatStringsSep ":" cfg.libraryPath;
|
||||
};
|
||||
serviceConfig = lib.mkMerge [
|
||||
{
|
||||
Type = "simple";
|
||||
User = "stable-video-diffusion";
|
||||
Group = "stable-video-diffusion";
|
||||
WorkingDirectory = cfg.stateDir;
|
||||
ExecStart = lib.getExe' cfg.package "stable-video-diffusion-api";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
TimeoutStopSec = "30s";
|
||||
KillSignal = "SIGTERM";
|
||||
KillMode = "mixed";
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
}
|
||||
(lib.mkIf (cfg.environmentFile != null) {
|
||||
EnvironmentFile = cfg.environmentFile;
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
# Matrix Cluster Failover Runbook (`accord.tube`)
|
||||
|
||||
Primary: `hectic-lab` (NL, `128.140.75.58`)
|
||||
Standby: `bfs.poland.xray` (PL, `91.198.166.181`)
|
||||
|
||||
Module: `hectic.generic.matrix-cluster` (`nixos/module/generic/matrix-cluster.nix`).
|
||||
Shared secrets: `sus/matrix-cluster.yaml`.
|
||||
|
||||
All `psql` and `pg_ctl` invocations use PostgreSQL **17** at data dir
|
||||
`/var/lib/postgresql/17`.
|
||||
|
||||
## Initial setup
|
||||
|
||||
### 1. Provision shared SOPS file (`sus/matrix-cluster.yaml`)
|
||||
|
||||
On a workstation with both yukkop and yukkop-alt age keys available:
|
||||
|
||||
```sh
|
||||
sudo cat /var/lib/matrix-synapse/homeserver.signing.key # on NL (hectic-lab)
|
||||
# Copy the single line value into the buffer for the next step.
|
||||
|
||||
sops sus/matrix-cluster.yaml
|
||||
```
|
||||
|
||||
Populate the editor with:
|
||||
|
||||
```yaml
|
||||
matrix:
|
||||
signing-key: <paste verbatim signing-key line from NL>
|
||||
postgres-replication-password: <openssl rand -base64 32>
|
||||
object-storage:
|
||||
credentials: |
|
||||
ACCESS_KEY_ID=<verbatim copy from sus/hectic-lab.yaml>
|
||||
SECRET_ACCESS_KEY=<verbatim copy from sus/hectic-lab.yaml>
|
||||
porkbun-api-key: <PORKBUN_API_KEY>
|
||||
porkbun-secret-api-key: <PORKBUN_SECRET_API_KEY>
|
||||
```
|
||||
|
||||
Verify recipients:
|
||||
|
||||
```sh
|
||||
sops updatekeys sus/matrix-cluster.yaml
|
||||
sops -d sus/matrix-cluster.yaml | grep -E 'signing-key|porkbun-api-key|object-storage'
|
||||
```
|
||||
|
||||
Expected: all five keys present, exit 0.
|
||||
|
||||
### 2. Deploy NL primary first
|
||||
|
||||
```sh
|
||||
nixos-rebuild switch --flake .#'hectic-lab|x86_64-linux' --target-host root@128.140.75.58
|
||||
```
|
||||
|
||||
Verify on NL:
|
||||
|
||||
```sh
|
||||
sudo systemctl status matrix-synapse postgresql matrix-cluster-replication-password
|
||||
sudo -u postgres psql -c "select rolname, rolreplication from pg_roles where rolname='replication';"
|
||||
# Expected: replication | t
|
||||
```
|
||||
|
||||
### 3. Seed PL replica with `pg_basebackup`
|
||||
|
||||
On PL:
|
||||
|
||||
```sh
|
||||
sudo systemctl stop postgresql
|
||||
sudo rm -rf /var/lib/postgresql/17
|
||||
sudo -u postgres install -d -m 0700 /var/lib/postgresql/17
|
||||
sudo -u postgres PGPASSWORD="$(sudo cat /run/secrets/matrix/postgres-replication-password)" \
|
||||
pg_basebackup \
|
||||
-h 128.140.75.58 \
|
||||
-p 5432 \
|
||||
-U replication \
|
||||
-D /var/lib/postgresql/17 \
|
||||
-Fp -Xs -P -R \
|
||||
--no-password
|
||||
```
|
||||
|
||||
`-R` writes `standby.signal` and an initial `primary_conninfo`. The
|
||||
matrix-cluster module's `matrix-cluster-standby-bootstrap` service will
|
||||
overwrite `primary_conninfo` to use a libpq passfile on next boot.
|
||||
|
||||
### 4. Deploy PL standby
|
||||
|
||||
```sh
|
||||
nixos-rebuild switch --flake .#'bfs.poland.xray|x86_64-linux' --target-host root@91.198.166.181
|
||||
sudo systemctl start postgresql
|
||||
```
|
||||
|
||||
Verify streaming on NL:
|
||||
|
||||
```sh
|
||||
sudo -u postgres psql -c 'select client_addr, state, sync_state from pg_stat_replication;'
|
||||
# Expected: 91.198.166.181 | streaming | async
|
||||
```
|
||||
|
||||
Verify standby on PL:
|
||||
|
||||
```sh
|
||||
sudo -u postgres psql -c 'select pg_is_in_recovery();'
|
||||
# Expected: t
|
||||
sudo systemctl is-active matrix-synapse
|
||||
# Expected: inactive (standby keeps Synapse off)
|
||||
```
|
||||
|
||||
### 5. Remove duplicate S3 credentials from `sus/hectic-lab.yaml`
|
||||
|
||||
Only AFTER NL is confirmed healthy reading from the new shared file:
|
||||
|
||||
```sh
|
||||
sops sus/hectic-lab.yaml
|
||||
# Delete the matrix/object-storage/credentials block.
|
||||
sudo nixos-rebuild switch --flake .#'hectic-lab|x86_64-linux'
|
||||
```
|
||||
|
||||
## Normal operations
|
||||
|
||||
```sh
|
||||
# NL: replication health
|
||||
sudo -u postgres psql -c 'select * from pg_stat_replication;'
|
||||
# Expected: 1 row, state=streaming, sync_state=async
|
||||
|
||||
# PL: replay status
|
||||
sudo -u postgres psql -c 'select now() - pg_last_xact_replay_timestamp() as lag;'
|
||||
|
||||
# Both: cert renewal
|
||||
sudo systemctl status acme-accord.tube.timer
|
||||
sudo journalctl -u acme-accord.tube.service --since '24 hours ago'
|
||||
|
||||
# Synapse health (NL primary)
|
||||
curl -sf https://accord.tube/_matrix/client/versions | head
|
||||
```
|
||||
|
||||
## Planned failover (NL -> PL)
|
||||
|
||||
```sh
|
||||
# 1. Drain NL: stop accepting writes.
|
||||
sudo systemctl stop matrix-synapse
|
||||
sudo systemctl stop postgresql # ensure no new WAL after this point
|
||||
|
||||
# 2. Promote PL replica.
|
||||
sudo -u postgres pg_ctl -D /var/lib/postgresql/17 promote
|
||||
# Wait until pg_is_in_recovery() returns f:
|
||||
sudo -u postgres psql -c 'select pg_is_in_recovery();'
|
||||
|
||||
# 3. Make the role switch declarative before rebuilding.
|
||||
# Edit the flake so rebuilds match the promoted database state:
|
||||
# - nixos/system/bfs.poland.xray/bfs.poland.xray.nix:
|
||||
# hectic.generic.matrix-cluster.role = "primary";
|
||||
# hectic.generic.matrix-cluster.overrideEnableSynapse = true;
|
||||
# hectic.generic.matrix-cluster.secretsFile = config.sops.secrets."matrix/secrets".path;
|
||||
# - nixos/system/hectic-lab/hectic-lab.nix:
|
||||
# hectic.generic.matrix-cluster.role = "standby";
|
||||
# hectic.generic.matrix-cluster.overrideEnableSynapse = false;
|
||||
# hectic.generic.matrix-cluster.replication.peerHost = "91.198.166.181";
|
||||
# hectic.generic.matrix-cluster.replication.allowedSourceIPs = [ "128.140.75.58/32" ];
|
||||
# (You will also need a matrix/secrets entry on PL - copy from NL via SOPS.)
|
||||
sudo nixos-rebuild switch --flake .#'bfs.poland.xray|x86_64-linux'
|
||||
sudo nixos-rebuild switch --flake .#'hectic-lab|x86_64-linux'
|
||||
sudo systemctl status matrix-synapse
|
||||
|
||||
# 4. Swap DNS A record at Porkbun:
|
||||
# accord.tube A 91.198.166.181 (was 128.140.75.58)
|
||||
# TTL: set to 300 in advance of any planned failover.
|
||||
# Porkbun UI: https://porkbun.com/account/domainsSpeedy -> accord.tube -> DNS -> edit A record.
|
||||
# Or via API:
|
||||
sudo curl -sX POST https://api.porkbun.com/api/json/v3/dns/editByNameType/accord.tube/A \
|
||||
-H 'content-type: application/json' \
|
||||
-d "$(jq -n --arg k "$PORKBUN_API_KEY" --arg s "$PORKBUN_SECRET_API_KEY" \
|
||||
'{secretapikey:$s,apikey:$k,content:"91.198.166.181",ttl:"300"}')"
|
||||
|
||||
# 5. Federation smoke test.
|
||||
curl -s 'https://federationtester.matrix.org/api/report?server_name=accord.tube' | jq .FederationOK
|
||||
# Expected: true
|
||||
```
|
||||
|
||||
Expected after the rebuilds:
|
||||
|
||||
- `bfs.poland.xray` evaluates and runs as `role = "primary"`.
|
||||
- `hectic-lab` evaluates as `role = "standby"` with Synapse forced off.
|
||||
- Future `nixos-rebuild` runs preserve the promoted topology instead of reapplying standby settings to PL.
|
||||
|
||||
## Failback (PL -> NL)
|
||||
|
||||
```sh
|
||||
# 1. Stop NL postgres if still up; clear its data dir.
|
||||
sudo systemctl stop postgresql matrix-synapse
|
||||
sudo rm -rf /var/lib/postgresql/17
|
||||
|
||||
# 2. Re-seed NL from PL (now the live primary).
|
||||
sudo -u postgres install -d -m 0700 /var/lib/postgresql/17
|
||||
sudo -u postgres PGPASSWORD="$(sudo cat /run/secrets/matrix/postgres-replication-password)" \
|
||||
pg_basebackup -h 91.198.166.181 -p 5432 -U replication \
|
||||
-D /var/lib/postgresql/17 -Fp -Xs -P -R --no-password
|
||||
|
||||
# 3. Temporarily flip roles in the flake:
|
||||
# - hectic-lab.nix: role = "standby"; peerHost = "91.198.166.181";
|
||||
# - bfs.poland.xray.nix: role = "primary"; peerHost = "128.140.75.58";
|
||||
# Rebuild both.
|
||||
|
||||
# 4. Once NL is streaming green, do the reverse failover dance:
|
||||
sudo systemctl stop matrix-synapse # on PL
|
||||
sudo -u postgres pg_ctl -D /var/lib/postgresql/17 promote # on NL
|
||||
# Then revert the flake role assignments back to NL=primary / PL=standby and
|
||||
# rebuild both hosts.
|
||||
|
||||
# 5. Swap DNS back at Porkbun (A -> 128.140.75.58).
|
||||
```
|
||||
|
||||
## Disaster recovery (NL permanently lost)
|
||||
|
||||
```sh
|
||||
# 1. Promote PL as the new permanent primary.
|
||||
sudo -u postgres pg_ctl -D /var/lib/postgresql/17 promote
|
||||
|
||||
# 2. Edit nixos/system/bfs.poland.xray/bfs.poland.xray.nix:
|
||||
# hectic.generic.matrix-cluster.role = "primary";
|
||||
# hectic.generic.matrix-cluster.overrideEnableSynapse = lib.mkForce null;
|
||||
# hectic.generic.matrix-cluster.replication.peerHost = "<new-standby-ip>";
|
||||
# hectic.generic.matrix-cluster.replication.allowedSourceIPs = [ "<new-standby-ip>/32" ];
|
||||
|
||||
# 3. Provision a new host (any region with Porkbun-managed DNS) and import
|
||||
# self.nixosModules.matrix-cluster with role = "standby" pointed at PL's IP.
|
||||
|
||||
# 4. Bootstrap the new standby via pg_basebackup from PL exactly as in
|
||||
# "Initial setup" step 3, replacing 128.140.75.58 with PL's IP.
|
||||
|
||||
# 5. Update Porkbun A record to PL's IP permanently.
|
||||
```
|
||||
@@ -9,17 +9,11 @@
|
||||
config,
|
||||
...
|
||||
}: let
|
||||
matrixBackend = "https://128.140.75.58";
|
||||
matrixHost = "accord.tube";
|
||||
jitsiHost = "meet.accord.tube";
|
||||
elementEntryDomain = "element.bfs.band";
|
||||
polandEntryDomain = "bfs.band";
|
||||
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
|
||||
backendProxyConfig = ''
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name ${matrixHost};
|
||||
proxy_set_header Host ${matrixHost};
|
||||
'';
|
||||
in {
|
||||
imports = [
|
||||
self.nixosModules.xray-system
|
||||
@@ -47,7 +41,6 @@ in {
|
||||
credentialsFile = config.sops.secrets."matrix/object-storage/credentials".path;
|
||||
};
|
||||
replication = {
|
||||
peerHost = "128.140.75.58";
|
||||
passwordFile = config.sops.secrets."matrix/postgres-replication-password".path;
|
||||
};
|
||||
acme = {
|
||||
@@ -72,6 +65,14 @@ in {
|
||||
hostName = jitsiHost;
|
||||
};
|
||||
|
||||
# NOTE(yukkop): disk was provisioned outside disko, so the expected partition
|
||||
# label does not exist. Pin root to the live filesystem UUID so stage 1 can
|
||||
# mount `/` reliably.
|
||||
fileSystems."/" = lib.mkForce {
|
||||
device = "/dev/disk/by-uuid/06b48ef1-a1eb-428d-821c-90c96a624542";
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = "security@bfs.band";
|
||||
@@ -125,19 +126,17 @@ in {
|
||||
};
|
||||
|
||||
locations."= /livekit/jwt" = {
|
||||
proxyPass = "${matrixBackend}/livekit/jwt";
|
||||
extraConfig = backendProxyConfig;
|
||||
proxyPass = "http://[::1]:${toString config.services.lk-jwt-service.port}/";
|
||||
};
|
||||
|
||||
locations."^~ /livekit/jwt/" = {
|
||||
proxyPass = "${matrixBackend}/livekit/jwt/";
|
||||
extraConfig = backendProxyConfig;
|
||||
proxyPass = "http://[::1]:${toString config.services.lk-jwt-service.port}/";
|
||||
};
|
||||
|
||||
locations."= /livekit/sfu" = {
|
||||
proxyPass = "${matrixBackend}/livekit/sfu";
|
||||
proxyPass = "http://[::1]:${toString config.services.livekit.settings.port}/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = backendProxyConfig + ''
|
||||
extraConfig = ''
|
||||
proxy_send_timeout 120;
|
||||
proxy_read_timeout 120;
|
||||
proxy_buffering off;
|
||||
@@ -148,9 +147,9 @@ in {
|
||||
};
|
||||
|
||||
locations."^~ /livekit/sfu/" = {
|
||||
proxyPass = "${matrixBackend}/livekit/sfu/";
|
||||
proxyPass = "http://[::1]:${toString config.services.livekit.settings.port}/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = backendProxyConfig + ''
|
||||
extraConfig = ''
|
||||
proxy_send_timeout 120;
|
||||
proxy_read_timeout 120;
|
||||
proxy_buffering off;
|
||||
@@ -161,13 +160,14 @@ in {
|
||||
};
|
||||
|
||||
locations."^~ /_matrix/" = {
|
||||
proxyPass = "${matrixBackend}/_matrix/";
|
||||
extraConfig = backendProxyConfig;
|
||||
proxyPass = "http://127.0.0.1:8008";
|
||||
extraConfig = ''
|
||||
client_max_body_size ${config.hectic.generic.matrix-cluster.maxUploadSize};
|
||||
'';
|
||||
};
|
||||
|
||||
locations."^~ /_synapse/client/" = {
|
||||
proxyPass = "${matrixBackend}/_synapse/client/";
|
||||
extraConfig = backendProxyConfig;
|
||||
proxyPass = "http://127.0.0.1:8008";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
port = 22222;
|
||||
stateDir = "/var/lib/experimental-sshd";
|
||||
configFile = pkgs.writeText "experimental-sshd_config" ''
|
||||
Port 22222
|
||||
ListenAddress 0.0.0.0
|
||||
ListenAddress ::
|
||||
|
||||
HostKey ${stateDir}/ssh_host_ed25519_key
|
||||
HostKey ${stateDir}/ssh_host_rsa_key
|
||||
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
PermitRootLogin prohibit-password
|
||||
UsePAM yes
|
||||
AuthenticationMethods publickey
|
||||
AuthorizedKeysFile %h/.ssh/authorized_keys /etc/ssh/authorized_keys.d/%u
|
||||
LogLevel DEBUG3
|
||||
|
||||
VersionAddendum none
|
||||
HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-ed25519
|
||||
KexAlgorithms curve25519-sha256,diffie-hellman-group14-sha256
|
||||
Ciphers aes256-ctr,aes128-ctr
|
||||
MACs hmac-sha2-512,hmac-sha2-256
|
||||
'';
|
||||
|
||||
keygenScript = pkgs.writeShellScript "experimental-sshd-keygen" ''
|
||||
set -eu
|
||||
|
||||
${pkgs.coreutils}/bin/mkdir -p -m 0700 ${stateDir}
|
||||
|
||||
if [ ! -f ${stateDir}/ssh_host_ed25519_key ]; then
|
||||
${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f ${stateDir}/ssh_host_ed25519_key -N ""
|
||||
fi
|
||||
|
||||
if [ ! -f ${stateDir}/ssh_host_rsa_key ]; then
|
||||
${pkgs.openssh}/bin/ssh-keygen -t rsa -b 4096 -f ${stateDir}/ssh_host_rsa_key -N ""
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
environment.etc."ssh/experimental-sshd_config".source = configFile;
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ port ];
|
||||
|
||||
systemd.services.experimental-sshd-keygen = {
|
||||
description = "Generate experimental SSH host keys";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
StateDirectory = "experimental-sshd";
|
||||
ExecStart = keygenScript;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.experimental-sshd = {
|
||||
description = "Experimental SSH daemon";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "experimental-sshd-keygen.service" ];
|
||||
after = [ "network.target" "experimental-sshd-keygen.service" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
StateDirectory = "experimental-sshd";
|
||||
RuntimeDirectory = "experimental-sshd";
|
||||
ExecStart = "${pkgs.openssh}/bin/sshd -D -e -f /etc/ssh/experimental-sshd_config";
|
||||
};
|
||||
preStart = ''
|
||||
${pkgs.openssh}/bin/sshd -t -f /etc/ssh/experimental-sshd_config
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,7 @@ with builtins;
|
||||
with lib;
|
||||
let
|
||||
domain = "hectic-lab.com";
|
||||
matrixDomain = "accord.tube";
|
||||
sshPort = 22;
|
||||
mailUserNames = [
|
||||
"security"
|
||||
"founders"
|
||||
@@ -57,6 +57,7 @@ in {
|
||||
|
||||
(import ./attic.nix { inherit flake self inputs domain; })
|
||||
(import ./containers.nix { inherit flake self inputs; })
|
||||
./experimental-sshd.nix
|
||||
(import ./ente.nix { inherit domain; })
|
||||
(import ./mechabellum.nix { inherit flake self inputs domain; })
|
||||
(import (./. + "/sentinèlla.nix") { inherit flake self inputs domain; })
|
||||
@@ -80,6 +81,7 @@ in {
|
||||
enable = true;
|
||||
networkMatchConfigName = "enp1s0";
|
||||
ipv4 = "128.140.75.58";
|
||||
floatingIpv4 = "78.47.243.0";
|
||||
ipv6 = "2a01:4f8:c2c:d54a";
|
||||
};
|
||||
services.matrix = {
|
||||
@@ -106,6 +108,7 @@ in {
|
||||
'';
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
tcpdump
|
||||
git
|
||||
rsync
|
||||
python311
|
||||
@@ -133,6 +136,8 @@ in {
|
||||
};
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
# neuro machine
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfqSROY+rp7amPPiArY3sZM7jTjYBS02csWxF/NeIr/ root@neuro"
|
||||
# yukkop
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMuP5NSfEQmO6m77xBWZvZ3hk7cw1q2k2vbsFd37rybU u0_a327@localhost"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJBLxMo5icX2Xyng7mcWGnIi+c4ZbVygjPhuU8noCkfZ"
|
||||
@@ -159,6 +164,8 @@ in {
|
||||
];
|
||||
};
|
||||
|
||||
services.openssh.ports = [ sshPort ];
|
||||
|
||||
services.mailserver = {
|
||||
enable = true;
|
||||
domain = domain;
|
||||
@@ -179,10 +186,10 @@ in {
|
||||
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [
|
||||
sshPort # ssh
|
||||
80
|
||||
443
|
||||
3306 # mysql
|
||||
11012 # gitea ssh
|
||||
25565
|
||||
55228 # ss-bfs
|
||||
];
|
||||
@@ -264,7 +271,7 @@ in {
|
||||
settings.service.DISABLE_REGISTRATION = true;
|
||||
settings.server = {
|
||||
HTTP_PORT = 11011;
|
||||
#SSH_PORT = 22;
|
||||
SSH_PORT = sshPort;
|
||||
SSH_DOMAIN = "hectic-lab.com";
|
||||
};
|
||||
database = {
|
||||
|
||||
@@ -15,32 +15,12 @@ in self.lib.nixpkgs-lib.nixosSystem {
|
||||
self.overlays.default
|
||||
inputs.nix-minecraft.overlay
|
||||
];
|
||||
config.allowUnfreePredicate = pkg: builtins.elem (self.lib.nixpkgs-lib.getName pkg) [
|
||||
config.allowUnfreePredicate = pkg:
|
||||
self.lib.cudaUnfreePredicate pkg || builtins.elem (self.lib.nixpkgs-lib.getName pkg) [
|
||||
"minecraft-server"
|
||||
"neoforge"
|
||||
|
||||
"nvidia-x11"
|
||||
|
||||
"cuda_nvcc"
|
||||
"cuda_cudart"
|
||||
"cuda_cuobjdump"
|
||||
"cuda_cupti"
|
||||
"cuda_nvdisasm"
|
||||
"cuda_cccl"
|
||||
"cuda_nvml_dev"
|
||||
"cuda_nvrtc"
|
||||
"cuda_nvtx"
|
||||
"cuda_profiler_api"
|
||||
|
||||
"libcusparse_lt"
|
||||
"libcublas"
|
||||
"libcufft"
|
||||
"libcufile"
|
||||
"libcurand"
|
||||
"libcusolver"
|
||||
"libnvjitlink"
|
||||
"libcusparse"
|
||||
"cudnn"
|
||||
];
|
||||
# jitsi-meet depends on libolm which is marked insecure (CVE-2024-4519x)
|
||||
config.permittedInsecurePackages = [
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
|
||||
ollamaServiceBundledLibraryPath = "${ollamaPrebuilt}/lib/ollama:${ollamaPrebuilt}/lib/ollama/cuda_v12:${ollamaPrebuilt}/lib/ollama/cuda_v13";
|
||||
|
||||
stableVideoDiffusionLibraryPath = lib.makeLibraryPath [
|
||||
pkgs.stdenv.cc.cc.lib
|
||||
pkgs.zlib
|
||||
];
|
||||
|
||||
ollamaPrebuilt = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "ollama";
|
||||
version = "0.22.1";
|
||||
@@ -176,6 +181,19 @@ in {
|
||||
openFirewall = false;
|
||||
};
|
||||
|
||||
hectic.services.stable-video-diffusion = {
|
||||
enable = true;
|
||||
host = "127.0.0.1";
|
||||
port = 7861;
|
||||
package = pkgs.hectic.stable-video-diffusion-api;
|
||||
device = "cuda";
|
||||
libraryPath = [
|
||||
stableVideoDiffusionLibraryPath
|
||||
"/run/opengl-driver/lib"
|
||||
];
|
||||
openFirewall = false;
|
||||
};
|
||||
|
||||
networking = {
|
||||
networkmanager.enable = true;
|
||||
useDHCP = lib.mkDefault true;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
flake,
|
||||
self,
|
||||
inputs,
|
||||
system ? "x86_64-linux",
|
||||
...
|
||||
}: let
|
||||
# Use folder name as name of this system
|
||||
name = builtins.baseNameOf ./.;
|
||||
|
||||
in self.lib.nixpkgs-lib.nixosSystem {
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ self.overlays.default ];
|
||||
};
|
||||
modules = [
|
||||
{ networking.hostName = name; }
|
||||
(import ./${name}.nix { inherit flake self inputs; })
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
inputs,
|
||||
self,
|
||||
...
|
||||
}: {
|
||||
pkgs,
|
||||
modulesPath,
|
||||
...
|
||||
}: {
|
||||
imports = [
|
||||
(modulesPath + "/profiles/qemu-guest.nix")
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
self.nixosModules.hectic
|
||||
];
|
||||
|
||||
hectic = {
|
||||
archetype.dev.enable = true;
|
||||
hardware.hetzner-cloud = {
|
||||
enable = true;
|
||||
device = "/dev/sda";
|
||||
networkMatchConfigMac = "92:00:08:4a:b0:32";
|
||||
ipv4 = "46.225.237.218";
|
||||
ipv6 = "2a01:4f8:c2c:3b14";
|
||||
};
|
||||
};
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKAaObjLBslsdTlqEcYaS1TqX4x9aVJu75y27/8MFevO''
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
git
|
||||
rsync
|
||||
];
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
# db-tool
|
||||
|
||||
PostgreSQL development database management tool. Drop-in replacement for per-project database.sh / postgres-init.sh / postgres-cleanup.sh scripts. Provides database, postgres-init, and postgres-cleanup binaries.
|
||||
PostgreSQL utility package exposing separate development and operations binaries.
|
||||
`db-dev` preserves the current development workflow via the `database` binary;
|
||||
`db-ops` provides target-safe operational helpers such as secrets hydration.
|
||||
|
||||
## Provided Binaries
|
||||
|
||||
| Binary | Description |
|
||||
| --- | --- |
|
||||
| `database` | Main script for managing migrations, deployments, and logs. |
|
||||
| `database` | Main `db-dev` script for managing local development databases, migrations, deployments, and logs. |
|
||||
| `db-ops` | Operational helper binary for target/runtime database actions. |
|
||||
| `postgres-init` | Ephemeral PostgreSQL cluster initialization and startup. |
|
||||
| `postgres-cleanup` | Graceful shutdown and cleanup of the PostgreSQL cluster. |
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
These variables must be set for `db-tool` to function.
|
||||
These variables must be set for `db-dev` / `database` to function.
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
@@ -39,7 +42,7 @@ These variables must be set for `db-tool` to function.
|
||||
|
||||
## Postgres Package Override
|
||||
|
||||
By default, `db-tool`/`postgres-init`/`postgres-cleanup` use plain `postgresql_17` from nixpkgs. If you need extensions (e.g. `pg_cron`), override the postgres package per-output:
|
||||
By default, `db-dev`/`db-ops`/`postgres-init`/`postgres-cleanup` use plain `postgresql_17` from nixpkgs. If you need extensions (e.g. `pg_cron`), override the postgres package per-output:
|
||||
|
||||
```nix
|
||||
let
|
||||
@@ -48,7 +51,8 @@ let
|
||||
]);
|
||||
in {
|
||||
packages = [
|
||||
(pkgs.hectic."db-tool".override { postgresql = myPg; })
|
||||
(pkgs.hectic."db-dev".override { postgresql = myPg; })
|
||||
(pkgs.hectic."db-ops".override { postgresql = myPg; })
|
||||
(pkgs.hectic."postgres-init".override { postgresql = myPg; })
|
||||
(pkgs.hectic."postgres-cleanup".override { postgresql = myPg; })
|
||||
];
|
||||
@@ -102,14 +106,14 @@ used by `database restore`.
|
||||
|
||||
## shellHook Example
|
||||
|
||||
To use `db-tool` in a Nix development shell, add the following to your `flake.nix` or `shell.nix`:
|
||||
To use `db-dev` in a Nix development shell, add the following to your `flake.nix` or `shell.nix`:
|
||||
|
||||
```nix
|
||||
{
|
||||
# ...
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.hectic.db-tool
|
||||
pkgs.hectic.db-dev
|
||||
pkgs.hectic.postgres-init
|
||||
pkgs.hectic.postgres-cleanup
|
||||
];
|
||||
@@ -134,7 +138,7 @@ To use `db-tool` in a Nix development shell, add the following to your `flake.ni
|
||||
|
||||
## hectic Bundle
|
||||
|
||||
`db-tool` and `migrator` apply a single bundle of SQL files that bootstrap the
|
||||
`db-dev`, `db-ops`, and `migrator` apply a single bundle of SQL files that bootstrap the
|
||||
`hectic` schema. The bundle lives in
|
||||
[`lib/hook/sql/`](../../lib/hook/sql/README.md) — see that README for full
|
||||
contract, file layout, and the `self.lib.hectic.*` Nix API.
|
||||
@@ -190,6 +194,7 @@ ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl'
|
||||
| `postgres-init` | **No.** Pure PostgreSQL provisioner — starts a vanilla cluster, nothing more. |
|
||||
| `migrator init` | **Yes, mandatory.** The bundle is a hard prerequisite for `hectic.migration`. |
|
||||
| `database hydrate` | **Yes, by default.** Re-applied on every hydrate. Skip with `--no-hook`. After applying the bundle, hydrate also calls `hectic.load_secrets_from_env(<dotenv>)` if `HECTIC_DOTENV_FILE` (or `${LOCAL_DIR}/.env.${ENVIRONMENT}`) is readable. |
|
||||
| `db-ops secrets load` | **Yes, mandatory.** Applies the bundle and requires a dotenv source before loading secrets into `hectic.secret`. |
|
||||
|
||||
The bundle is idempotent — repeated application is safe.
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# shellcheck shell=dash
|
||||
|
||||
: "${SCRIPT_NAME:=$(basename "$0")}"
|
||||
|
||||
help() {
|
||||
# shellcheck disable=SC2059
|
||||
printf "$(cat <<EOF
|
||||
${BGREEN}Usage:${NC} $SCRIPT_NAME [OPTIONS] <SUBCOMMAND> [OPTIONS]
|
||||
|
||||
PostgreSQL operations utility.
|
||||
|
||||
${BGREEN}Global Options:${NC}
|
||||
${BCYAN}-h${NC}, ${BCYAN}--help${NC} Show this help message
|
||||
${BCYAN}-u${NC}, ${BCYAN}--url${NC} <url> PostgreSQL connection string
|
||||
|
||||
${BGREEN}Subcommands:${NC}
|
||||
${BCYAN}secrets load${NC} [OPTIONS] Apply hectic bundle and load secrets
|
||||
|
||||
${BGREEN}Environment:${NC}
|
||||
${BBLACK}PGURL${NC} PostgreSQL connection string fallback
|
||||
${BBLACK}DB_URL${NC} PostgreSQL connection string fallback
|
||||
${BBLACK}HECTIC_DOTENV_CONTENT${NC} Raw dotenv content to load
|
||||
${BBLACK}HECTIC_DOTENV_FILE${NC} Dotenv file to read when readable
|
||||
${BBLACK}LOCAL_DIR${NC} Used with ENVIRONMENT for .env fallback
|
||||
${BBLACK}ENVIRONMENT${NC} Falls back to ${BBLACK}\$LOCAL_DIR/.env.\$ENVIRONMENT${NC}
|
||||
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
help_secrets_load() {
|
||||
# shellcheck disable=SC2059
|
||||
printf "$(cat <<EOF
|
||||
${BGREEN}Usage:${NC} $SCRIPT_NAME ${BCYAN}secrets load${NC} [OPTIONS]
|
||||
|
||||
Apply the hectic SQL bundle and load dotenv-backed secrets into
|
||||
${BBLACK}hectic.secret${NC}.
|
||||
|
||||
${BGREEN}Options:${NC}
|
||||
${BCYAN}-h${NC}, ${BCYAN}--help${NC} Show this help message
|
||||
${BCYAN}-u${NC}, ${BCYAN}--url${NC} <url> PostgreSQL connection string
|
||||
${BCYAN}-f${NC}, ${BCYAN}--dotenv-file${NC} <path> Dotenv file path
|
||||
|
||||
${BGREEN}Resolution order:${NC}
|
||||
1. ${BBLACK}HECTIC_DOTENV_CONTENT${NC}
|
||||
2. ${BBLACK}--dotenv-file${NC} / ${BBLACK}HECTIC_DOTENV_FILE${NC}
|
||||
3. ${BBLACK}\$LOCAL_DIR/.env.\$ENVIRONMENT${NC}
|
||||
|
||||
Fails with exit code ${BBLACK}3${NC} when neither a PostgreSQL URL nor a dotenv
|
||||
source can be resolved.
|
||||
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
resolve_pgurl() {
|
||||
if [ -n "${PGURL:-}" ]; then
|
||||
printf '%s' "$PGURL"
|
||||
elif [ -n "${DB_URL:-}" ]; then
|
||||
printf '%s' "$DB_URL"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_dotenv_content() {
|
||||
dotenv_file="${1:-}"
|
||||
|
||||
if [ -n "${HECTIC_DOTENV_CONTENT:-}" ]; then
|
||||
printf '%s' "$HECTIC_DOTENV_CONTENT"
|
||||
elif [ -n "$dotenv_file" ]; then
|
||||
if [ ! -f "$dotenv_file" ] || [ ! -r "$dotenv_file" ]; then
|
||||
return 2
|
||||
fi
|
||||
cat "$dotenv_file"
|
||||
elif [ -n "${ENVIRONMENT:-}" ] && [ -n "${LOCAL_DIR:-}" ] && [ -r "${LOCAL_DIR}/.env.${ENVIRONMENT}" ]; then
|
||||
cat "${LOCAL_DIR}/.env.${ENVIRONMENT}"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
subcommand_secrets_load() {
|
||||
change_namespace 'db ops secrets load'
|
||||
|
||||
dotenv_file="${HECTIC_DOTENV_FILE:-}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
help_secrets_load
|
||||
restore_namespace
|
||||
exit 0
|
||||
;;
|
||||
-u|--url)
|
||||
if [ $# -lt 2 ]; then
|
||||
log error "missing value for $1"
|
||||
restore_namespace
|
||||
exit 3
|
||||
fi
|
||||
PGURL=$2
|
||||
DB_URL=$2
|
||||
shift 2
|
||||
;;
|
||||
-f|--dotenv-file)
|
||||
if [ $# -lt 2 ]; then
|
||||
log error "missing value for $1"
|
||||
restore_namespace
|
||||
exit 3
|
||||
fi
|
||||
dotenv_file=$2
|
||||
shift 2
|
||||
;;
|
||||
--*|-*)
|
||||
log error "secrets load argument $1 does not exist"
|
||||
restore_namespace
|
||||
exit 9
|
||||
;;
|
||||
*)
|
||||
log error "secrets load subcommand $1 does not exist"
|
||||
restore_namespace
|
||||
exit 9
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! pgurl="$(resolve_pgurl)"; then
|
||||
log error "PGURL or DB_URL is required"
|
||||
restore_namespace
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if dotenv_content="$(resolve_dotenv_content "$dotenv_file")"; then
|
||||
:
|
||||
else
|
||||
dotenv_content_exit_code=$?
|
||||
if [ "$dotenv_content_exit_code" -eq 2 ]; then
|
||||
log error "dotenv file is not readable: $dotenv_file"
|
||||
else
|
||||
log error "dotenv source is required (HECTIC_DOTENV_CONTENT, readable dotenv file, or \$LOCAL_DIR/.env.\$ENVIRONMENT)"
|
||||
fi
|
||||
restore_namespace
|
||||
exit 3
|
||||
fi
|
||||
|
||||
log notice "apply hectic bundle and load secrets"
|
||||
apply_hectic_bundle "$pgurl" "$dotenv_content"
|
||||
restore_namespace
|
||||
}
|
||||
|
||||
subcommand_secrets() {
|
||||
change_namespace 'db ops secrets'
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
help_secrets_load
|
||||
restore_namespace
|
||||
exit 0
|
||||
fi
|
||||
|
||||
subcommand=$1
|
||||
shift
|
||||
|
||||
case $subcommand in
|
||||
load)
|
||||
subcommand_secrets_load "$@"
|
||||
;;
|
||||
-h|--help)
|
||||
help_secrets_load
|
||||
restore_namespace
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log error "secrets subcommand $subcommand does not exist"
|
||||
restore_namespace
|
||||
exit 9
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
help
|
||||
exit 0
|
||||
;;
|
||||
-u|--url)
|
||||
if [ $# -lt 2 ]; then
|
||||
log error "missing value for $1"
|
||||
exit 3
|
||||
fi
|
||||
PGURL=$2
|
||||
DB_URL=$2
|
||||
shift 2
|
||||
;;
|
||||
--*|-*)
|
||||
log error "argument $1 does not exist"
|
||||
exit 9
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
subcommand="${1:-}"
|
||||
|
||||
if [ -z "$subcommand" ]; then
|
||||
help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
shift
|
||||
|
||||
case $subcommand in
|
||||
secrets)
|
||||
subcommand_secrets "$@"
|
||||
;;
|
||||
*)
|
||||
log error "subcommand $subcommand does not exist"
|
||||
exit 9
|
||||
;;
|
||||
esac
|
||||
@@ -8,7 +8,7 @@ let
|
||||
|
||||
applyBundle = self.lib.hectic.applyBundleScript;
|
||||
|
||||
mkDatabase =
|
||||
mkDbDev =
|
||||
{ postgresql ? postgresql_17 }:
|
||||
hectic.writeShellApplication {
|
||||
inherit shell;
|
||||
@@ -27,7 +27,7 @@ let
|
||||
${builtins.readFile hectic.helpers.posix-shell.pager_or_cat}
|
||||
${builtins.readFile hectic.helpers.posix-shell.with_closed_fds}
|
||||
${applyBundle}
|
||||
${builtins.readFile ./database.sh}
|
||||
${builtins.readFile ./db-dev.sh}
|
||||
'';
|
||||
|
||||
meta = {
|
||||
@@ -36,6 +36,31 @@ let
|
||||
};
|
||||
};
|
||||
|
||||
mkDbOps =
|
||||
{ postgresql ? postgresql_17 }:
|
||||
hectic.writeShellApplication {
|
||||
inherit shell;
|
||||
bashOptions = [
|
||||
"errexit"
|
||||
"nounset"
|
||||
];
|
||||
excludeShellChecks = [ "SC2209" ];
|
||||
name = "db-ops";
|
||||
runtimeInputs = [ postgresql coreutils ];
|
||||
|
||||
text = ''
|
||||
${builtins.readFile hectic.helpers.posix-shell.log}
|
||||
${builtins.readFile hectic.helpers.posix-shell.change_namespace}
|
||||
${applyBundle}
|
||||
${builtins.readFile ./db-ops.sh}
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "PostgreSQL operations utility";
|
||||
mainProgram = "db-ops";
|
||||
};
|
||||
};
|
||||
|
||||
mkPostgresInit =
|
||||
{ postgresql ? postgresql_17 }:
|
||||
hectic.writeShellApplication {
|
||||
@@ -73,7 +98,8 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
"db-tool" = lib.makeOverridable mkDatabase { };
|
||||
"db-dev" = lib.makeOverridable mkDbDev { };
|
||||
"db-ops" = lib.makeOverridable mkDbOps { };
|
||||
"postgres-init" = lib.makeOverridable mkPostgresInit { };
|
||||
"postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { };
|
||||
"hectic-inheritance" = hecticInheritance;
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
#!/bin/dash
|
||||
|
||||
# Stop a local PostgreSQL cluster started by postgres-init.
|
||||
#
|
||||
# Public contract:
|
||||
# - Accepts PG_WORKING_DIR directly, or derives it from LOCAL_DIR.
|
||||
# - If no cluster root can be resolved, exits successfully without doing
|
||||
# anything. This keeps cleanup safe in traps and partial-init flows.
|
||||
# - If no postmaster.pid exists, treats the cluster as already stopped.
|
||||
# - NO_TTY=1 redirects pg_ctl output into a temp log file instead of writing to
|
||||
# the current terminal.
|
||||
|
||||
postgres_cleanup_help() {
|
||||
cat <<'EOF'
|
||||
Usage: postgres-cleanup
|
||||
|
||||
Stop a local PostgreSQL cluster if it is running.
|
||||
|
||||
Environment:
|
||||
PG_WORKING_DIR Cluster root directory
|
||||
LOCAL_DIR Fallback root used to derive PG_WORKING_DIR
|
||||
NO_TTY Redirect pg_ctl output to a temp file
|
||||
|
||||
Behavior:
|
||||
- Returns success if the cluster directory cannot be resolved.
|
||||
- Returns success if postmaster.pid is already absent.
|
||||
- Ignores pg_ctl stop errors so cleanup remains trap-safe.
|
||||
EOF
|
||||
}
|
||||
|
||||
postgres_cleanup_main() {
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
postgres_cleanup_help
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "${PG_WORKING_DIR:-}" ] && [ -z "${LOCAL_DIR:-}" ]; then return 0; fi
|
||||
: "${PG_WORKING_DIR:=$LOCAL_DIR/focus/postgresql}"
|
||||
if [ -f "${PG_WORKING_DIR}/data/postmaster.pid" ]; then
|
||||
# Cleanup is intentionally forgiving so it can be used in traps after
|
||||
# partial startup failures without masking the real error.
|
||||
if [ "${NO_TTY:-0}" = "1" ]; then
|
||||
_pg_log="$(mktemp /tmp/postgres-cleanup.XXXXXX.log)"
|
||||
pg_ctl -D "${PG_WORKING_DIR}/data" -m fast -w stop > "$_pg_log" 2>&1 || :
|
||||
|
||||
@@ -1,6 +1,58 @@
|
||||
#!/bin/dash
|
||||
|
||||
# Initialize or reuse a local PostgreSQL cluster for development workflows.
|
||||
#
|
||||
# Public contract:
|
||||
# - Requires either PG_WORKING_DIR or LOCAL_DIR.
|
||||
# - Produces a ready-to-use database and exports:
|
||||
# POSTGRESQL_HOST, POSTGRESQL_PORT, POSTGRESQL_USER, POSTGRESQL_DATABASE,
|
||||
# PGURL, and also the variable named by PG_URL_VAR.
|
||||
# - Reuses an existing cluster only when PG_REUSE is set and PG_VERSION exists.
|
||||
# - If a reused cluster cannot start, it is reinitialized automatically.
|
||||
#
|
||||
# Important knobs:
|
||||
# - PG_WORKING_DIR: cluster root (defaults to $LOCAL_DIR/focus/postgresql)
|
||||
# - PG_DATABASE: database to create/ensure (default: testdb)
|
||||
# - PG_PORT: socket port (default: 5432)
|
||||
# - PG_URL_VAR: alternate URL variable to export in addition to PGURL
|
||||
# - PG_CONF_FILE: base postgresql.conf to copy on fresh init
|
||||
# - PG_SHARED_PRELOAD_LIBRARIES: preload list; empty string disables pg_cron
|
||||
# - PG_DISABLE_LOGGING=1: disable logging_collector in generated config
|
||||
# - NO_TTY=1: redirect pg_ctl stop/start output into temp log files
|
||||
|
||||
postgres_init_help() {
|
||||
cat <<'EOF'
|
||||
Usage: postgres-init
|
||||
|
||||
Initialize or reuse a local PostgreSQL cluster.
|
||||
|
||||
Environment:
|
||||
PG_WORKING_DIR Cluster root directory
|
||||
LOCAL_DIR Fallback root used to derive PG_WORKING_DIR
|
||||
PG_DATABASE Database name to create/ensure (default: testdb)
|
||||
PG_PORT PostgreSQL port / unix socket port (default: 5432)
|
||||
PG_URL_VAR Extra URL variable to export alongside PGURL
|
||||
PG_CONF_FILE Base postgresql.conf copied on fresh init only
|
||||
PG_SHARED_PRELOAD_LIBRARIES Preload list; empty disables pg_cron preload
|
||||
PG_DISABLE_LOGGING Set to 1 to disable logging_collector
|
||||
PG_REUSE Reuse existing cluster if PG_VERSION exists
|
||||
NO_TTY Redirect pg_ctl output to temp files
|
||||
|
||||
Behavior:
|
||||
- Rewrites runtime socket/port/cron settings on every launch.
|
||||
- Creates the target database if missing.
|
||||
- If a reused cluster exists but cannot start, reinitializes it automatically.
|
||||
EOF
|
||||
}
|
||||
|
||||
postgres_init_main() {
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
postgres_init_help
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "${PG_WORKING_DIR:-}" ] && [ -z "${LOCAL_DIR:-}" ]; then
|
||||
printf '%s\n' 'postgres-init: PG_WORKING_DIR or LOCAL_DIR is required' >&2
|
||||
return 1
|
||||
@@ -25,20 +77,41 @@ postgres_init_main() {
|
||||
fi
|
||||
mkdir -p "$sockdir" || return 1
|
||||
|
||||
# Reuse is optimistic: if a cluster exists on disk, try to start it first.
|
||||
# We only destroy state after a real startup failure proves the cluster is
|
||||
# broken, which keeps long-lived local DBs intact when possible.
|
||||
if [ "${PG_REUSE+x}" ] && [ -f "$data/PG_VERSION" ]; then PG_REUSE=1; else PG_REUSE=0; fi
|
||||
|
||||
if [ "$PG_REUSE" -eq 0 ]; then
|
||||
rm -rf "$data" "$sockdir" || return 1
|
||||
mkdir -p "$sockdir" || return 1
|
||||
initdb -D "$data" --no-locale -E UTF8 || return 1
|
||||
if [ -n "${PG_CONF_FILE:-}" ]; then
|
||||
# PG_CONF_FILE replaces the base postgresql.conf on fresh init only. We
|
||||
# still append runtime values later so the helper can relocate sockets,
|
||||
# ports, and cron settings regardless of the custom file contents.
|
||||
[ -r "$PG_CONF_FILE" ] || { printf '%s\n' "postgres-init: PG_CONF_FILE not readable: $PG_CONF_FILE" >&2; return 1; }
|
||||
cp -f -- "$PG_CONF_FILE" "$data/postgresql.conf" || return 1
|
||||
else
|
||||
{ printf '%s\n' "listen_addresses = ''"; [ "$PG_DISABLE_LOGGING" -eq 0 ] && { printf '%s\n' 'logging_collector = on'; printf '%s\n' "log_directory = 'log'"; }; [ -n "$PG_SHARED_PRELOAD_LIBRARIES" ] && { printf '%s\n' "shared_preload_libraries = '$PG_SHARED_PRELOAD_LIBRARIES'"; printf '%s\n' "cron.database_name = '$db'"; printf '%s\n' "cron.host = '$sockdir'"; }; :; } >> "$data/postgresql.conf" || return 1
|
||||
{
|
||||
printf '%s\n' "listen_addresses = ''"
|
||||
if [ "$PG_DISABLE_LOGGING" -eq 0 ]; then
|
||||
printf '%s\n' 'logging_collector = on'
|
||||
printf '%s\n' "log_directory = 'log'"
|
||||
fi
|
||||
if [ -n "$PG_SHARED_PRELOAD_LIBRARIES" ]; then
|
||||
printf '%s\n' "shared_preload_libraries = '$PG_SHARED_PRELOAD_LIBRARIES'"
|
||||
printf '%s\n' "cron.database_name = '$db'"
|
||||
printf '%s\n' "cron.host = '$sockdir'"
|
||||
fi
|
||||
} >> "$data/postgresql.conf" || return 1
|
||||
fi
|
||||
sed -i "1ilocal all all trust" "$data/pg_hba.conf" || return 1
|
||||
fi
|
||||
|
||||
# Rewrite the runtime knobs on every launch. Reused clusters may have been
|
||||
# started from another workspace/session, so we must override stale socket,
|
||||
# port, and pg_cron wiring before attempting startup.
|
||||
[ -f "$data/postgresql.auto.conf" ] || : > "$data/postgresql.auto.conf"
|
||||
[ -f "$data/pg_ident.conf" ] || : > "$data/pg_ident.conf"
|
||||
|
||||
@@ -68,12 +141,85 @@ postgres_init_main() {
|
||||
printf '%s\n' "cron.host = '$sockdir'"
|
||||
fi
|
||||
} >> "$data/postgresql.auto.conf" || return 1
|
||||
|
||||
_pg_start_exit=0
|
||||
if [ "${NO_TTY:-0}" = "1" ]; then
|
||||
_pg_log="$(mktemp /tmp/postgres-init-start.XXXXXX.log)"
|
||||
with_closed_fds pg_ctl -D "$data" -o "-F" -w start >"$_pg_log" 2>&1 || return 2
|
||||
with_closed_fds pg_ctl -D "$data" -o "-F" -w start >"$_pg_log" 2>&1 || _pg_start_exit=$?
|
||||
printf '%s\n' "postgres-init: pg_ctl start output redirected to $_pg_log" >&2
|
||||
else
|
||||
with_closed_fds pg_ctl -D "$data" -o "-F" -w start || return 2
|
||||
with_closed_fds pg_ctl -D "$data" -o "-F" -w start || _pg_start_exit=$?
|
||||
fi
|
||||
|
||||
if [ "$_pg_start_exit" -ne 0 ]; then
|
||||
if [ "$PG_REUSE" -eq 1 ]; then
|
||||
# Self-heal path: the on-disk cluster exists but cannot complete startup
|
||||
# (for example bad WAL / invalid checkpoint). At this point preserving
|
||||
# the broken state is less useful than reinitializing automatically.
|
||||
printf '%s\n' "postgres-init: reused cluster failed to start; reinitializing $wd" >&2
|
||||
PG_REUSE=0
|
||||
rm -rf "$data" "$sockdir" || return 1
|
||||
mkdir -p "$sockdir" || return 1
|
||||
initdb -D "$data" --no-locale -E UTF8 || return 1
|
||||
if [ -n "${PG_CONF_FILE:-}" ]; then
|
||||
[ -r "$PG_CONF_FILE" ] || { printf '%s\n' "postgres-init: PG_CONF_FILE not readable: $PG_CONF_FILE" >&2; return 1; }
|
||||
cp -f -- "$PG_CONF_FILE" "$data/postgresql.conf" || return 1
|
||||
else
|
||||
{
|
||||
printf '%s\n' "listen_addresses = ''"
|
||||
if [ "$PG_DISABLE_LOGGING" -eq 0 ]; then
|
||||
printf '%s\n' 'logging_collector = on'
|
||||
printf '%s\n' "log_directory = 'log'"
|
||||
fi
|
||||
if [ -n "$PG_SHARED_PRELOAD_LIBRARIES" ]; then
|
||||
printf '%s\n' "shared_preload_libraries = '$PG_SHARED_PRELOAD_LIBRARIES'"
|
||||
printf '%s\n' "cron.database_name = '$db'"
|
||||
printf '%s\n' "cron.host = '$sockdir'"
|
||||
fi
|
||||
} >> "$data/postgresql.conf" || return 1
|
||||
fi
|
||||
sed -i "1ilocal all all trust" "$data/pg_hba.conf" || return 1
|
||||
[ -f "$data/postgresql.auto.conf" ] || : > "$data/postgresql.auto.conf"
|
||||
[ -f "$data/pg_ident.conf" ] || : > "$data/pg_ident.conf"
|
||||
sed -i '/^[[:space:]]*port[[:space:]]*=/d' "$data/postgresql.conf" || return 1
|
||||
sed -i '/^[[:space:]]*unix_socket_directories[[:space:]]*=/d' "$data/postgresql.conf" || return 1
|
||||
sed -i '/^[[:space:]]*port[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
sed -i '/^[[:space:]]*unix_socket_directories[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
sed -i '/^[[:space:]]*hba_file[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
sed -i '/^[[:space:]]*ident_file[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
sed -i '/^[[:space:]]*shared_preload_libraries[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
sed -i '/^[[:space:]]*cron\.database_name[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
sed -i '/^[[:space:]]*cron\.host[[:space:]]*=/d' "$data/postgresql.auto.conf" || return 1
|
||||
{ printf '%s\n' "port = $PG_PORT"; printf '%s\n' "unix_socket_directories = '$sockdir'"; } >> "$data/postgresql.conf" || return 1
|
||||
{
|
||||
printf '%s\n' "port = '$PG_PORT'"
|
||||
printf '%s\n' "unix_socket_directories = '$sockdir'"
|
||||
printf '%s\n' "hba_file = '$data/pg_hba.conf'"
|
||||
printf '%s\n' "ident_file = '$data/pg_ident.conf'"
|
||||
if [ "$PG_DISABLE_LOGGING" -eq 0 ]; then
|
||||
printf '%s\n' "logging_collector = 'on'"
|
||||
else
|
||||
printf '%s\n' "logging_collector = 'off'"
|
||||
fi
|
||||
if [ -n "$PG_SHARED_PRELOAD_LIBRARIES" ]; then
|
||||
printf '%s\n' "shared_preload_libraries = '$PG_SHARED_PRELOAD_LIBRARIES'"
|
||||
printf '%s\n' "cron.database_name = '$db'"
|
||||
printf '%s\n' "cron.host = '$sockdir'"
|
||||
fi
|
||||
} >> "$data/postgresql.auto.conf" || return 1
|
||||
_pg_start_exit=0
|
||||
if [ "${NO_TTY:-0}" = "1" ]; then
|
||||
_pg_log="$(mktemp /tmp/postgres-init-start.XXXXXX.log)"
|
||||
with_closed_fds pg_ctl -D "$data" -o "-F" -w start >"$_pg_log" 2>&1 || _pg_start_exit=$?
|
||||
printf '%s\n' "postgres-init: pg_ctl start output redirected to $_pg_log" >&2
|
||||
else
|
||||
with_closed_fds pg_ctl -D "$data" -o "-F" -w start || _pg_start_exit=$?
|
||||
fi
|
||||
[ "$_pg_start_exit" -eq 0 ] || return 2
|
||||
printf '%s\n' "postgres-init: reinitialized broken cluster at $wd" >&2
|
||||
else
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
user="$(id -un)" || return 1
|
||||
@@ -87,9 +233,13 @@ postgres_init_main() {
|
||||
psql -h "$sockdir" -p "$PG_PORT" -d "$db" -v ON_ERROR_STOP=1 -c 'select 1;' || return 1
|
||||
|
||||
export POSTGRESQL_HOST="$sockdir" POSTGRESQL_PORT="$PG_PORT" POSTGRESQL_USER="$user" POSTGRESQL_DATABASE="$db"
|
||||
_pg_url="postgresql://${POSTGRESQL_USER}@/${POSTGRESQL_DATABASE}?host=${POSTGRESQL_HOST}&port=${POSTGRESQL_PORT}"
|
||||
# Export both names for compatibility: some callers consume plain PGURL,
|
||||
# while multi-cluster shells also need a project-specific variable like
|
||||
# WIPE_PGURL or BMSEARCH_PGURL in the same environment.
|
||||
PGURL="postgresql://${POSTGRESQL_USER}@/${POSTGRESQL_DATABASE}?host=${POSTGRESQL_HOST}&port=${POSTGRESQL_PORT}"
|
||||
export PGURL
|
||||
case $PG_URL_VAR in ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]* ) printf '%s\n' 'postgres-init: invalid PG_URL_VAR' >&2; return 1 ;; esac
|
||||
export "${PG_URL_VAR}=${_pg_url}" || return 1
|
||||
export "${PG_URL_VAR}=${PGURL}" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -132,6 +132,7 @@ in {
|
||||
github-gh-tl = pkgs.callPackage ./github/gh-tl.nix {};
|
||||
supabase-with-env-collection = pkgs.callPackage ./supabase-with-env-collection.nix {};
|
||||
migration-name = pkgs.callPackage ./migration-name.nix {};
|
||||
plgo = pkgs.callPackage ./plgo {};
|
||||
pg-from = pkgs.callPackage ./postgres/pg-from/default.nix rust.commonArgs;
|
||||
pg-schema = pkgs.callPackage ./postgres/pg-schema/default.nix rust.commonArgs;
|
||||
pg_wdumpall = pkgs.callPackage ./postgres/pg_wdumpall.nix rust.commonArgs;
|
||||
@@ -148,10 +149,12 @@ in {
|
||||
deploy = pkgs.callPackage ./deploy { inherit inputs; };
|
||||
element-web = pkgs.callPackage ./element-web {};
|
||||
shellplot = pkgs.callPackage ./shellplot {};
|
||||
which-country-rs = pkgs.callPackage ./which-country-rs {};
|
||||
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
|
||||
migrator = pkgs.callPackage ./migrator { inherit self; };
|
||||
"parse-uri" = pkgs.callPackage ./parse-uri {};
|
||||
"db-tool" = dbToolPkgs."db-tool";
|
||||
"db-dev" = dbToolPkgs."db-dev";
|
||||
"db-ops" = dbToolPkgs."db-ops";
|
||||
"postgres-init" = dbToolPkgs."postgres-init";
|
||||
"postgres-cleanup" = dbToolPkgs."postgres-cleanup";
|
||||
"hectic-inheritance" = dbToolPkgs."hectic-inheritance";
|
||||
@@ -173,5 +176,6 @@ in {
|
||||
pg-15-ext-smtp-client = buildSmtpExt pkgs "15";
|
||||
pg-15-ext-plhaskell = buildPlHaskellExt pkgs "15";
|
||||
pg-15-ext-plsh = buildPlShExt pkgs "15";
|
||||
stable-video-diffusion-api = pkgs.callPackage ./stable-video-diffusion-api {};
|
||||
media-browser = pkgs.callPackage ./media-browser {};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ let
|
||||
hash = "sha256-pbzuPgKJ0DmrDSTO7ZTDArX+Xr9k/ndAGZvQg2kMTMQ=";
|
||||
};
|
||||
|
||||
inherit patches;
|
||||
#inherit patches; #WIP
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = "element";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{ dash, hectic, sqlite, postgresql_17, gawk, self }:
|
||||
{ dash, hectic, sqlite, postgresql_17, gawk, coreutils, self }:
|
||||
let
|
||||
shell = "${dash}/bin/dash";
|
||||
bashOptions = [
|
||||
@@ -11,7 +11,7 @@ let
|
||||
migrator = hectic.writeShellApplication {
|
||||
inherit shell bashOptions;
|
||||
name = "migrator";
|
||||
runtimeInputs = [ sqlite postgresql_17 gawk ];
|
||||
runtimeInputs = [ sqlite postgresql_17 gawk coreutils ];
|
||||
|
||||
text = ''
|
||||
${builtins.readFile hectic.helpers.posix-shell.log}
|
||||
|
||||
@@ -154,11 +154,11 @@ init() {
|
||||
shift 2
|
||||
;;
|
||||
--*|-*)
|
||||
printf 'init argument %s does not exists' "$1"
|
||||
log error "init argument ${WHITE}$1${NC} does not exists"
|
||||
exit 9
|
||||
;;
|
||||
*)
|
||||
printf 'init command %s does not exists' "$1"
|
||||
log error "init subcommand ${WHITE}$1${NC} does not exists"
|
||||
exit 9
|
||||
;;
|
||||
esac
|
||||
@@ -434,6 +434,8 @@ migrate_down() {
|
||||
target_migration=$(printf '%s' "$fs_migrations" | sed -n "${target_line}p")
|
||||
printf '%s' "$target_migration"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
migrate_up() {
|
||||
@@ -488,6 +490,7 @@ migrate_up() {
|
||||
fi
|
||||
|
||||
printf '%s' "$target_migration"
|
||||
return 0
|
||||
}
|
||||
|
||||
migrate_to() {
|
||||
@@ -518,6 +521,8 @@ migrate_to() {
|
||||
printf '%s' "$migration_name"
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
migration_list() {
|
||||
@@ -566,7 +571,10 @@ index_of() {
|
||||
|
||||
# no subshell, no pipeline
|
||||
while IFS= read -r m; do
|
||||
[ "$m" = "$name" ] && { printf '%s\n' "$i"; return 0; }
|
||||
if [ "$m" = "$name" ]; then
|
||||
printf '%s\n' "$i"
|
||||
return 0
|
||||
fi
|
||||
i=$((i+1))
|
||||
done <<EOF
|
||||
$list
|
||||
@@ -633,7 +641,8 @@ migrate() {
|
||||
db_mig_count=$(printf '%s' "$db_migrations" | wc -l)
|
||||
log debug "mig count: $db_mig_count"
|
||||
|
||||
# Log migration lists for debugging
|
||||
# These counts are useful even at normal verbosity because they summarize the
|
||||
# migration state before any validation or apply/revert work begins.
|
||||
fs_mig_count=$(printf '%s' "$fs_migrations" | wc -l)
|
||||
log info "Filesystem migrations found: ${WHITE}$fs_mig_count"
|
||||
log info "Database migrations applied: ${WHITE}$db_mig_count"
|
||||
@@ -696,26 +705,35 @@ migrate() {
|
||||
log info "Migration history validation: ${GREEN}OK${NC} (${WHITE}$i${NC} migrations match)"
|
||||
|
||||
eval "set -- $MIGRATOR_REMAINING_ARS"
|
||||
target_migration="$("migrate_$MIGRATE_SUBCOMMAND" "$@")"
|
||||
if ! target_migration="$("migrate_$MIGRATE_SUBCOMMAND" "$@")"; then
|
||||
log error "failed to resolve migration target for ${WHITE}migrate $MIGRATE_SUBCOMMAND${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$db_migrations" ]; then
|
||||
log info "it'll firs migration"
|
||||
log info "starting from clean migration state"
|
||||
current_idx=0
|
||||
else
|
||||
current_migration=$(printf '%s\n' "$db_migrations" | tail -n1)
|
||||
current_idx=$(index_of "$fs_migrations" "$current_migration")
|
||||
if ! current_idx=$(index_of "$fs_migrations" "$current_migration"); then
|
||||
log error "current database migration is missing from filesystem: ${WHITE}$current_migration${NC}"
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
log debug "[$WHITE$fs_migrations$NC]"
|
||||
log debug "$target_migration"
|
||||
log debug "filesystem migrations: ${WHITE}$fs_migrations${NC}"
|
||||
log debug "requested target migration: ${WHITE}${target_migration:-<clean state>}${NC}"
|
||||
|
||||
if [ -z "$target_migration" ]; then
|
||||
target_idx=0
|
||||
else
|
||||
target_idx=$(index_of "$fs_migrations" "$target_migration")
|
||||
if ! target_idx=$(index_of "$fs_migrations" "$target_migration"); then
|
||||
log error "target migration is missing from filesystem: ${WHITE}$target_migration${NC}"
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
log debug "indexes $WHITE$current_idx$NC $WHITE${target_idx}"
|
||||
log debug "migration indexes: current=${WHITE}$current_idx${NC} target=${WHITE}$target_idx${NC}"
|
||||
|
||||
if [ "$target_idx" -eq "$current_idx" ]; then
|
||||
if [ "$target_idx" -eq 0 ]; then
|
||||
@@ -903,11 +921,11 @@ list() {
|
||||
shift
|
||||
;;
|
||||
--*|-*)
|
||||
log error "init argument $1 does not exists"
|
||||
log error "list argument ${WHITE}$1${NC} does not exists"
|
||||
exit 9
|
||||
;;
|
||||
*)
|
||||
log error "init subcommand $1 does not exists"
|
||||
log error "list subcommand ${WHITE}$1${NC} does not exists"
|
||||
exit 9
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
buildGoModule,
|
||||
fetchFromGitHub,
|
||||
lib,
|
||||
}:
|
||||
buildGoModule {
|
||||
pname = "plgo";
|
||||
version = "0.2.1-unstable-2026-04-04";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "mgr43";
|
||||
repo = "plgo";
|
||||
rev = "492abd81f50f5f588794bd3f9fa103d14546b275";
|
||||
hash = "sha256-Ifd2IFA62VtkqCUP0/cD3jT4SqkjqwCpHVolslQbwkA=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-KIVHepJRfVa0bkTF1A4g6ZFGxDy8OPzY3iwV3MhRyVA=";
|
||||
|
||||
proxyVendor = true;
|
||||
|
||||
subPackages = [ "cmd/plgo" ];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
meta = {
|
||||
description = "Write PostgreSQL extensions in Go";
|
||||
homepage = "https://github.com/mgr43/plgo";
|
||||
license = lib.licenses.bsd3;
|
||||
mainProgram = "plgo";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local HTTP API for Diffusers Stable Video Diffusion image-to-video."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
torch = import_module("torch")
|
||||
uvicorn = import_module("uvicorn")
|
||||
diffusers = import_module("diffusers")
|
||||
diffusers_utils = import_module("diffusers.utils")
|
||||
fastapi = import_module("fastapi")
|
||||
pil_image = import_module("PIL.Image")
|
||||
pil_unidentified_image_error = import_module("PIL").UnidentifiedImageError
|
||||
|
||||
FastAPI = fastapi.FastAPI
|
||||
File = fastapi.File
|
||||
Form = fastapi.Form
|
||||
HTTPException = fastapi.HTTPException
|
||||
Request = fastapi.Request
|
||||
UploadFile = fastapi.UploadFile
|
||||
StableVideoDiffusionPipeline = diffusers.StableVideoDiffusionPipeline
|
||||
export_to_video = diffusers_utils.export_to_video
|
||||
|
||||
|
||||
HOST = os.environ.get("SVD_API_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("SVD_API_PORT", "8000"))
|
||||
MODEL_ID = os.environ.get("SVD_MODEL_ID", "stabilityai/stable-video-diffusion-img2vid-xt")
|
||||
CACHE_DIR = os.environ.get("SVD_CACHE_DIR") or os.environ.get("HF_HOME")
|
||||
OUTPUT_DIR = Path(os.environ.get("SVD_OUTPUT_DIR", "/var/lib/stable-video-diffusion-api/outputs"))
|
||||
DEVICE = os.environ.get("SVD_DEVICE", "cuda" if torch.cuda.is_available() else "cpu")
|
||||
DTYPE = os.environ.get("SVD_DTYPE", "float16")
|
||||
ENABLE_CPU_OFFLOAD = os.environ.get("SVD_ENABLE_CPU_OFFLOAD", "0").lower() in ("1", "true", "yes", "on")
|
||||
DEFAULT_DECODE_CHUNK_SIZE = int(os.environ.get("SVD_DECODE_CHUNK_SIZE", "8"))
|
||||
DEFAULT_FPS = int(os.environ.get("SVD_FPS", "7"))
|
||||
|
||||
|
||||
app = FastAPI(title="Stable Video Diffusion API")
|
||||
pipeline_lock = threading.Lock()
|
||||
pipeline: Any | None = None
|
||||
|
||||
|
||||
def torch_dtype() -> Any:
|
||||
dtypes = {
|
||||
"float16": torch.float16,
|
||||
"fp16": torch.float16,
|
||||
"float32": torch.float32,
|
||||
"fp32": torch.float32,
|
||||
"bfloat16": torch.bfloat16,
|
||||
"bf16": torch.bfloat16,
|
||||
}
|
||||
try:
|
||||
return dtypes[DTYPE.lower()]
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(f"Unsupported SVD_DTYPE: {DTYPE}") from exc
|
||||
|
||||
|
||||
def get_pipeline() -> Any:
|
||||
global pipeline
|
||||
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
|
||||
with pipeline_lock:
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
|
||||
kwargs: dict[str, Any] = {"torch_dtype": torch_dtype()}
|
||||
if CACHE_DIR:
|
||||
kwargs["cache_dir"] = CACHE_DIR
|
||||
|
||||
loaded = StableVideoDiffusionPipeline.from_pretrained(MODEL_ID, **kwargs)
|
||||
if ENABLE_CPU_OFFLOAD:
|
||||
loaded.enable_model_cpu_offload()
|
||||
else:
|
||||
loaded.to(DEVICE)
|
||||
|
||||
pipeline = loaded
|
||||
return loaded
|
||||
|
||||
|
||||
def positive_int(name: str, value: Any, default: int) -> int:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} must be an integer") from exc
|
||||
if parsed <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} must be greater than zero")
|
||||
return parsed
|
||||
|
||||
|
||||
def optional_int(name: str, value: Any) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} must be an integer") from exc
|
||||
|
||||
|
||||
def optional_float(name: str, value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} must be a number") from exc
|
||||
|
||||
|
||||
def load_image_from_path(input_image_path: str) -> Any:
|
||||
path = Path(input_image_path).expanduser()
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=400, detail="input_image_path does not exist or is not a file")
|
||||
try:
|
||||
return pil_image.open(path).convert("RGB")
|
||||
except (OSError, pil_unidentified_image_error) as exc:
|
||||
raise HTTPException(status_code=400, detail="input_image_path could not be decoded as an image") from exc
|
||||
|
||||
|
||||
async def load_uploaded_image(image: UploadFile) -> Any:
|
||||
try:
|
||||
return pil_image.open(image.file).convert("RGB")
|
||||
except (OSError, pil_unidentified_image_error) as exc:
|
||||
raise HTTPException(status_code=400, detail="uploaded image could not be decoded") from exc
|
||||
|
||||
|
||||
async def json_payload(request: Request) -> dict[str, Any]:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if not content_type.startswith("application/json"):
|
||||
return {}
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="request body must be valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="JSON request body must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"model_loaded": pipeline is not None,
|
||||
"model_id": MODEL_ID,
|
||||
"device": DEVICE,
|
||||
"dtype": DTYPE,
|
||||
"cpu_offload": ENABLE_CPU_OFFLOAD,
|
||||
"cache_dir": CACHE_DIR,
|
||||
"output_dir": str(OUTPUT_DIR),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/generate")
|
||||
async def generate(
|
||||
request: Request,
|
||||
image: UploadFile | None = File(default=None),
|
||||
input_image_path: str | None = Form(default=None),
|
||||
num_frames: int | None = Form(default=None),
|
||||
num_inference_steps: int | None = Form(default=None),
|
||||
fps: int | None = Form(default=None),
|
||||
decode_chunk_size: int | None = Form(default=None),
|
||||
seed: int | None = Form(default=None),
|
||||
motion_bucket_id: int | None = Form(default=None),
|
||||
noise_aug_strength: float | None = Form(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = await json_payload(request)
|
||||
path = input_image_path or payload.get("input_image_path")
|
||||
upload = image
|
||||
|
||||
if upload is None and not path:
|
||||
raise HTTPException(status_code=400, detail="provide input_image_path or upload image")
|
||||
if upload is not None and path:
|
||||
raise HTTPException(status_code=400, detail="provide only one image source")
|
||||
|
||||
source_image = await load_uploaded_image(upload) if upload is not None else load_image_from_path(str(path))
|
||||
chunk_size = positive_int("decode_chunk_size", decode_chunk_size if decode_chunk_size is not None else payload.get("decode_chunk_size"), DEFAULT_DECODE_CHUNK_SIZE)
|
||||
output_fps = positive_int("fps", fps if fps is not None else payload.get("fps"), DEFAULT_FPS)
|
||||
request_seed = optional_int("seed", seed if seed is not None else payload.get("seed"))
|
||||
generator = None
|
||||
if request_seed is not None:
|
||||
generator = torch.Generator(device="cpu").manual_seed(request_seed)
|
||||
|
||||
call_args: dict[str, Any] = {
|
||||
"image": source_image,
|
||||
"decode_chunk_size": chunk_size,
|
||||
}
|
||||
for key, value in {
|
||||
"num_frames": num_frames if num_frames is not None else payload.get("num_frames"),
|
||||
"num_inference_steps": num_inference_steps if num_inference_steps is not None else payload.get("num_inference_steps"),
|
||||
"motion_bucket_id": motion_bucket_id if motion_bucket_id is not None else payload.get("motion_bucket_id"),
|
||||
}.items():
|
||||
parsed = optional_int(key, value)
|
||||
if parsed is not None:
|
||||
call_args[key] = parsed
|
||||
|
||||
parsed_noise = optional_float("noise_aug_strength", noise_aug_strength if noise_aug_strength is not None else payload.get("noise_aug_strength"))
|
||||
if parsed_noise is not None:
|
||||
call_args["noise_aug_strength"] = parsed_noise
|
||||
if generator is not None:
|
||||
call_args["generator"] = generator
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
output_path = OUTPUT_DIR / f"svd-{uuid.uuid4().hex}.mp4"
|
||||
|
||||
try:
|
||||
frames = get_pipeline()(**call_args).frames[0]
|
||||
export_to_video(frames, str(output_path), fps=output_fps)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"output_path": str(output_path),
|
||||
"model_id": MODEL_ID,
|
||||
"seed": request_seed,
|
||||
"fps": output_fps,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run("app:app", host=HOST, port=PORT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
{ pkgs }:
|
||||
|
||||
let
|
||||
pythonEnv = pkgs.python3.withPackages (ps: let
|
||||
torch = ps.torchWithCuda;
|
||||
accelerate = ps.accelerate.override { inherit torch; };
|
||||
in [
|
||||
accelerate
|
||||
ps.diffusers
|
||||
ps.fastapi
|
||||
ps.imageio
|
||||
ps.imageio-ffmpeg
|
||||
ps.pillow
|
||||
ps.python-multipart
|
||||
ps.safetensors
|
||||
torch
|
||||
ps.transformers
|
||||
ps.uvicorn
|
||||
]);
|
||||
in
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "stable-video-diffusion-api";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/libexec/stable-video-diffusion-api
|
||||
cp $src/app.py $out/libexec/stable-video-diffusion-api/app.py
|
||||
chmod +x $out/libexec/stable-video-diffusion-api/app.py
|
||||
|
||||
makeWrapper ${pythonEnv}/bin/python3 $out/bin/stable-video-diffusion-api \
|
||||
--add-flags $out/libexec/stable-video-diffusion-api/app.py \
|
||||
--set-default SVD_API_HOST 127.0.0.1 \
|
||||
--set-default SVD_API_PORT 8000 \
|
||||
--set-default SVD_MODEL_ID stabilityai/stable-video-diffusion-img2vid-xt \
|
||||
--set-default SVD_OUTPUT_DIR /var/lib/stable-video-diffusion-api/outputs
|
||||
'';
|
||||
}
|
||||
Generated
+1939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "which-country-rs"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "CLI tool that detects your country by IP and renders an ASCII map"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/krisfur/which-country-rs"
|
||||
keywords = ["cli", "geolocation", "ascii", "map"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[features]
|
||||
default = ["geoip"]
|
||||
geoip = ["reqwest"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
reqwest = { version = "0.13", features = ["blocking", "json"], optional = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Krzysztof Furman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,83 @@
|
||||
# which-country-rs
|
||||
|
||||
A CLI tool that detects your country from your IP address and renders an ASCII map zoomed into it, showing neighbouring borders and country codes.
|
||||
|
||||
## Example output
|
||||
|
||||
```bash
|
||||
which-country-rs -c DE
|
||||
```
|
||||
|
||||
```
|
||||
You appear to be in: Germany (DE)
|
||||
|
||||
·········· ·· ······ · ·
|
||||
· NO · ·· · ···
|
||||
· · ···· ··· ···········
|
||||
· ····· ·· ············
|
||||
····· ········ · ·· ···EE ·
|
||||
· ······ ······ ·· · LV····· ·····
|
||||
·· ··· ·DK ·· ··· ···· ··········· ···
|
||||
···· ··· ··· · · ··· ···· ··· LT ···· ···
|
||||
· ····· ···· ··· ############ ··················· ···
|
||||
IE ·· ····GB · ·····##### ## ## ···· BY
|
||||
· ·· ·· ··· ·NL # ## ··
|
||||
··· ····· ·· ····· ## # PL ·············
|
||||
····················BE ··# DE #######····· ····
|
||||
· ······· ···L## ### CZ ····· ······
|
||||
······· #### ###·········SK ······· ··
|
||||
····· ·###### ###### AT ········ ···············
|
||||
·· FR ··· CH ··#·············· HU ·· MD··
|
||||
· · ····· · ····SI··· ········· RO ····
|
||||
· · · ···HR········ RS···· ···
|
||||
········ ·· · ········ IT ··· ·····BA ·· ··············
|
||||
· ·· ········· ·········· ·· · · ···ME··XK···· ··
|
||||
····· ··· · ·· · ··· ···· ·· ··MK·· ·BG ·····
|
||||
· ·· ····· ··· ····· ···· ·AL············ ······
|
||||
· ·· ES · · · ·· ····· ···· ···· ······
|
||||
|
||||
Coordinates: 51.15°N, 10.55°E
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
# Auto-detect from IP
|
||||
which-country-rs
|
||||
|
||||
# Specify a country code
|
||||
which-country-rs -c JP
|
||||
|
||||
# Specify coordinates (supports negative values for south/west)
|
||||
which-country-rs --lat 40 --lon -74
|
||||
|
||||
# Custom map size
|
||||
which-country-rs -W 120 -H 40
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `-W, --width` | Map width in characters | 80 |
|
||||
| `-H, --height` | Map height in characters | 24 |
|
||||
| `-c, --country` | ISO 3166-1 alpha-2 country code (skips IP lookup) | |
|
||||
| `--lat` | Latitude (requires `--lon`) | |
|
||||
| `--lon` | Longitude (requires `--lat`) | |
|
||||
| `-V, --version` | Print version | |
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
To build without IP geolocation support (drops the `reqwest` dependency):
|
||||
|
||||
```
|
||||
cargo build --release --no-default-features
|
||||
```
|
||||
|
||||
## Map data
|
||||
|
||||
Country borders from [Natural Earth](https://www.naturalearthdata.com/) 110m admin-0 countries (public domain).
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
{ lib, pkgs, ... }: let
|
||||
src = ./.;
|
||||
in
|
||||
pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "which-country-rs";
|
||||
version = "1.0.0";
|
||||
|
||||
inherit src;
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
# NOTE(yukkop): upstream uses edition 2024, so do not pass the repo's
|
||||
# Rust 1.81 commonArgs here.
|
||||
# NOTE(yukkop): keep the Nix package offline-by-default; upstream's
|
||||
# default geoip feature calls a plaintext HTTP endpoint.
|
||||
cargoBuildFlags = ["--no-default-features"];
|
||||
cargoTestFlags = ["--no-default-features"];
|
||||
|
||||
meta = {
|
||||
description = "CLI tool that tells you which country contains coordinates";
|
||||
homepage = "https://github.com/krisfur/which-country-rs";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "which-country-rs";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeatureCollection {
|
||||
features: Vec<Feature>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Feature {
|
||||
properties: Properties,
|
||||
geometry: Geometry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Properties {
|
||||
#[serde(rename = "ISO_A2_EH")]
|
||||
iso_a2: String,
|
||||
#[serde(rename = "NAME")]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum Geometry {
|
||||
Polygon {
|
||||
coordinates: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
MultiPolygon {
|
||||
coordinates: Vec<Vec<Vec<[f64; 2]>>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct Country {
|
||||
pub iso_a2: String,
|
||||
pub name: String,
|
||||
/// Each polygon is a list of rings; ring 0 = outer, rest = holes
|
||||
pub polygons: Vec<Vec<Vec<[f64; 2]>>>,
|
||||
pub bbox: (f64, f64, f64, f64), // (min_lon, min_lat, max_lon, max_lat)
|
||||
pub label_pos: (f64, f64), // (lon, lat) — centroid of largest polygon
|
||||
}
|
||||
|
||||
/// Signed area of a ring (positive = CCW).
|
||||
fn ring_signed_area(ring: &[[f64; 2]]) -> f64 {
|
||||
let n = ring.len();
|
||||
if n < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut area = 0.0;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
area += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
|
||||
j = i;
|
||||
}
|
||||
area / 2.0
|
||||
}
|
||||
|
||||
/// Ray-casting point-in-ring test.
|
||||
fn point_in_ring(lon: f64, lat: f64, ring: &[[f64; 2]]) -> bool {
|
||||
let mut inside = false;
|
||||
let n = ring.len();
|
||||
if n < 3 {
|
||||
return false;
|
||||
}
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let xi = ring[i][0];
|
||||
let yi = ring[i][1];
|
||||
let xj = ring[j][0];
|
||||
let yj = ring[j][1];
|
||||
if ((yi > lat) != (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Find horizontal interior spans at a given latitude.
|
||||
/// Returns sorted pairs of (enter_lon, exit_lon).
|
||||
fn horizontal_spans(lat: f64, ring: &[[f64; 2]]) -> Vec<(f64, f64)> {
|
||||
let n = ring.len();
|
||||
if n < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut crossings = Vec::new();
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let yi = ring[i][1];
|
||||
let yj = ring[j][1];
|
||||
if (yi > lat) != (yj > lat) {
|
||||
let xi = ring[i][0];
|
||||
let xj = ring[j][0];
|
||||
crossings.push((xj - xi) * (lat - yi) / (yj - yi) + xi);
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
crossings.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
crossings.chunks_exact(2).map(|p| (p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
/// Find vertical interior spans at a given longitude.
|
||||
/// Returns sorted pairs of (enter_lat, exit_lat).
|
||||
fn vertical_spans(lon: f64, ring: &[[f64; 2]]) -> Vec<(f64, f64)> {
|
||||
let n = ring.len();
|
||||
if n < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut crossings = Vec::new();
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let xi = ring[i][0];
|
||||
let xj = ring[j][0];
|
||||
if (xi > lon) != (xj > lon) {
|
||||
let yi = ring[i][1];
|
||||
let yj = ring[j][1];
|
||||
crossings.push((yj - yi) * (lon - xi) / (xj - xi) + yi);
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
crossings.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
crossings.chunks_exact(2).map(|p| (p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
/// Find a good interior label point for a ring.
|
||||
/// Scans a grid of candidate points and picks the one that maximizes
|
||||
/// min(half_width, half_height) — the "most interior" point.
|
||||
fn ring_label_point(ring: &[[f64; 2]]) -> (f64, f64) {
|
||||
let min_lon = ring.iter().map(|c| c[0]).fold(f64::MAX, f64::min);
|
||||
let max_lon = ring.iter().map(|c| c[0]).fold(f64::MIN, f64::max);
|
||||
let min_lat = ring.iter().map(|c| c[1]).fold(f64::MAX, f64::min);
|
||||
let max_lat = ring.iter().map(|c| c[1]).fold(f64::MIN, f64::max);
|
||||
|
||||
let steps = 24;
|
||||
let mut best = ((min_lon + max_lon) / 2.0, (min_lat + max_lat) / 2.0);
|
||||
let mut best_score = 0.0f64;
|
||||
|
||||
for row in 1..steps {
|
||||
let lat = min_lat + (max_lat - min_lat) * row as f64 / steps as f64;
|
||||
let h_spans = horizontal_spans(lat, ring);
|
||||
|
||||
for &(span_left, span_right) in &h_spans {
|
||||
let mid_lon = (span_left + span_right) / 2.0;
|
||||
let half_w = (span_right - span_left) / 2.0;
|
||||
|
||||
// Measure vertical extent at this longitude
|
||||
let v_spans = vertical_spans(mid_lon, ring);
|
||||
for &(span_bot, span_top) in &v_spans {
|
||||
if lat >= span_bot && lat <= span_top {
|
||||
let half_h = ((lat - span_bot).min(span_top - lat)).min(half_w);
|
||||
let score = half_w.min(half_h);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best = (mid_lon, lat);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best
|
||||
}
|
||||
|
||||
pub fn load_countries(geojson: &str) -> Vec<Country> {
|
||||
let fc: FeatureCollection = serde_json::from_str(geojson).expect("Failed to parse GeoJSON");
|
||||
|
||||
fc.features
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let polygons = match f.geometry {
|
||||
Geometry::Polygon { coordinates } => vec![coordinates],
|
||||
Geometry::MultiPolygon { coordinates } => coordinates,
|
||||
};
|
||||
|
||||
let mut min_lon = f64::MAX;
|
||||
let mut min_lat = f64::MAX;
|
||||
let mut max_lon = f64::MIN;
|
||||
let mut max_lat = f64::MIN;
|
||||
|
||||
for poly in &polygons {
|
||||
for ring in poly {
|
||||
for coord in ring {
|
||||
let lon = coord[0];
|
||||
let lat = coord[1];
|
||||
if lon < min_lon {
|
||||
min_lon = lon;
|
||||
}
|
||||
if lon > max_lon {
|
||||
max_lon = lon;
|
||||
}
|
||||
if lat < min_lat {
|
||||
min_lat = lat;
|
||||
}
|
||||
if lat > max_lat {
|
||||
max_lat = lat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label inside the largest polygon
|
||||
let label_pos = polygons
|
||||
.iter()
|
||||
.filter(|p| !p.is_empty() && p[0].len() >= 3)
|
||||
.max_by(|a, b| {
|
||||
ring_signed_area(&a[0])
|
||||
.abs()
|
||||
.partial_cmp(&ring_signed_area(&b[0]).abs())
|
||||
.unwrap()
|
||||
})
|
||||
.map(|p| ring_label_point(&p[0]))
|
||||
.unwrap_or(((min_lon + max_lon) / 2.0, (min_lat + max_lat) / 2.0));
|
||||
|
||||
Country {
|
||||
iso_a2: f.properties.iso_a2,
|
||||
name: f.properties.name,
|
||||
polygons,
|
||||
bbox: (min_lon, min_lat, max_lon, max_lat),
|
||||
label_pos,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a point is inside a polygon (outer ring minus holes).
|
||||
fn point_in_polygon(lon: f64, lat: f64, rings: &[Vec<[f64; 2]>]) -> bool {
|
||||
if rings.is_empty() || !point_in_ring(lon, lat, &rings[0]) {
|
||||
return false;
|
||||
}
|
||||
// Must be outside all holes
|
||||
!rings[1..].iter().any(|hole| point_in_ring(lon, lat, hole))
|
||||
}
|
||||
|
||||
/// Check if a point falls inside a country.
|
||||
pub fn point_in_country(lon: f64, lat: f64, country: &Country) -> bool {
|
||||
let (min_lon, min_lat, max_lon, max_lat) = country.bbox;
|
||||
if lon < min_lon || lon > max_lon || lat < min_lat || lat > max_lat {
|
||||
return false;
|
||||
}
|
||||
country
|
||||
.polygons
|
||||
.iter()
|
||||
.any(|poly| point_in_polygon(lon, lat, poly))
|
||||
}
|
||||
|
||||
/// Find which country contains the given point, with a nearest-country
|
||||
/// fallback for when low-res coastlines cause a near miss.
|
||||
pub fn find_country(lon: f64, lat: f64, countries: &[Country]) -> Option<usize> {
|
||||
// Exact hit
|
||||
if let Some(idx) = countries.iter().position(|c| point_in_country(lon, lat, c)) {
|
||||
return Some(idx);
|
||||
}
|
||||
// Search in expanding rings up to ~1 degree
|
||||
for &offset in &[0.25, 0.5, 1.0] {
|
||||
for &(dlon, dlat) in &[
|
||||
(offset, 0.0),
|
||||
(-offset, 0.0),
|
||||
(0.0, offset),
|
||||
(0.0, -offset),
|
||||
(offset, offset),
|
||||
(offset, -offset),
|
||||
(-offset, offset),
|
||||
(-offset, -offset),
|
||||
] {
|
||||
if let Some(idx) = countries
|
||||
.iter()
|
||||
.position(|c| point_in_country(lon + dlon, lat + dlat, c))
|
||||
{
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "status")]
|
||||
pub enum GeoIpResponse {
|
||||
#[serde(rename = "success")]
|
||||
Success {
|
||||
country: String,
|
||||
#[serde(rename = "countryCode")]
|
||||
country_code: String,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
},
|
||||
#[serde(rename = "fail")]
|
||||
Fail { message: String },
|
||||
}
|
||||
|
||||
pub struct GeoIpResult {
|
||||
pub country: String,
|
||||
pub country_code: String,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
pub fn lookup() -> Result<GeoIpResult, String> {
|
||||
let url = "http://ip-api.com/json/?fields=status,message,countryCode,country,lat,lon";
|
||||
let resp = reqwest::blocking::get(url).map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
let data: GeoIpResponse = resp.json().map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
match data {
|
||||
GeoIpResponse::Success {
|
||||
country,
|
||||
country_code,
|
||||
lat,
|
||||
lon,
|
||||
} => Ok(GeoIpResult {
|
||||
country,
|
||||
country_code,
|
||||
lat,
|
||||
lon,
|
||||
}),
|
||||
GeoIpResponse::Fail { message } => Err(format!("GeoIP lookup failed: {message}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
mod geo;
|
||||
#[cfg(feature = "geoip")]
|
||||
mod geoip;
|
||||
mod render;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "which-country-rs", version, about = "Detect your country by IP and render an ASCII world map")]
|
||||
struct Args {
|
||||
/// Map width in characters
|
||||
#[arg(short = 'W', long, default_value_t = 80)]
|
||||
width: usize,
|
||||
|
||||
/// Map height in characters
|
||||
#[arg(short = 'H', long, default_value_t = 24)]
|
||||
height: usize,
|
||||
|
||||
/// Country code to display (e.g. "US", "FR", "JP") — skips IP lookup
|
||||
#[arg(short, long)]
|
||||
country: Option<String>,
|
||||
|
||||
/// Latitude (requires --lon too) — skips IP lookup, derives country from coordinates
|
||||
#[arg(long, requires = "lon", allow_hyphen_values = true)]
|
||||
lat: Option<f64>,
|
||||
|
||||
/// Longitude (requires --lat too)
|
||||
#[arg(long, requires = "lat", allow_hyphen_values = true)]
|
||||
lon: Option<f64>,
|
||||
}
|
||||
|
||||
static GEOJSON: &str = include_str!("../data/countries.geojson");
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let countries = geo::load_countries(GEOJSON);
|
||||
|
||||
let (country_name, country_code, lat, lon) = if let Some(code) = &args.country {
|
||||
// Direct country code — find it in the data
|
||||
let code_upper = code.to_uppercase();
|
||||
let c = countries
|
||||
.iter()
|
||||
.find(|c| c.iso_a2 == code_upper)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Unknown country code: {code_upper}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let (label_lon, label_lat) = c.label_pos;
|
||||
(c.name.clone(), code_upper, label_lat, label_lon)
|
||||
} else if let (Some(lat), Some(lon)) = (args.lat, args.lon) {
|
||||
// Coordinates provided — find which country contains the point
|
||||
let idx = geo::find_country(lon, lat, &countries)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("No country found at {lat}, {lon} (ocean?)");
|
||||
std::process::exit(1);
|
||||
});
|
||||
(
|
||||
countries[idx].name.clone(),
|
||||
countries[idx].iso_a2.clone(),
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
} else {
|
||||
// IP geolocation
|
||||
#[cfg(feature = "geoip")]
|
||||
{
|
||||
eprint!("Looking up your location... ");
|
||||
match geoip::lookup() {
|
||||
Ok(loc) => {
|
||||
eprintln!("done.");
|
||||
(loc.country, loc.country_code, loc.lat, loc.lon)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "geoip"))]
|
||||
{
|
||||
eprintln!("IP geolocation requires the 'geoip' feature. Use --country or --lat/--lon instead.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let map = render::render_map(&countries, &country_code, args.width, args.height);
|
||||
|
||||
println!("You appear to be in: {country_name} ({country_code})");
|
||||
println!();
|
||||
println!("{map}");
|
||||
println!();
|
||||
println!(
|
||||
"Coordinates: {:.2}\u{00b0}{}, {:.2}\u{00b0}{}",
|
||||
lat.abs(),
|
||||
if lat >= 0.0 { "N" } else { "S" },
|
||||
lon.abs(),
|
||||
if lon >= 0.0 { "E" } else { "W" },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use crate::geo::Country;
|
||||
|
||||
/// Render a zoomed-in ASCII map centered on the target country, showing borders and labels.
|
||||
pub fn render_map(
|
||||
countries: &[Country],
|
||||
target_code: &str,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> String {
|
||||
// Find the target country and compute its bbox
|
||||
let target_idx = countries
|
||||
.iter()
|
||||
.position(|c| c.iso_a2 == target_code)
|
||||
.expect("Target country not found in GeoJSON");
|
||||
|
||||
let (min_lon, min_lat, max_lon, max_lat) = countries[target_idx].bbox;
|
||||
|
||||
// Add generous padding so the surrounding continent is visible
|
||||
let lon_span = (max_lon - min_lon).max(4.0);
|
||||
let lat_span = (max_lat - min_lat).max(4.0);
|
||||
let pad_lon = lon_span * 1.0;
|
||||
let pad_lat = lat_span * 1.0;
|
||||
|
||||
let view_min_lon = (min_lon - pad_lon).max(-180.0);
|
||||
let view_max_lon = (max_lon + pad_lon).min(180.0);
|
||||
let view_min_lat = (min_lat - pad_lat).max(-90.0);
|
||||
let view_max_lat = (max_lat + pad_lat).min(90.0);
|
||||
|
||||
// Adjust aspect ratio: terminal chars are ~2x taller than wide
|
||||
let lon_range = view_max_lon - view_min_lon;
|
||||
let lat_range = view_max_lat - view_min_lat;
|
||||
let char_aspect = 2.0;
|
||||
|
||||
let (final_lon_range, final_lat_range);
|
||||
let desired_lon = lat_range * (width as f64) / (height as f64) / char_aspect;
|
||||
let desired_lat = lon_range * (height as f64) / (width as f64) * char_aspect;
|
||||
|
||||
if desired_lon > lon_range {
|
||||
final_lon_range = desired_lon;
|
||||
final_lat_range = lat_range;
|
||||
} else {
|
||||
final_lon_range = lon_range;
|
||||
final_lat_range = desired_lat;
|
||||
}
|
||||
|
||||
let center_lon = (view_min_lon + view_max_lon) / 2.0;
|
||||
let center_lat = (view_min_lat + view_max_lat) / 2.0;
|
||||
|
||||
let vp_min_lon = (center_lon - final_lon_range / 2.0).max(-180.0);
|
||||
let vp_max_lat = (center_lat + final_lat_range / 2.0).min(90.0);
|
||||
|
||||
let lon_per_col = final_lon_range / width as f64;
|
||||
let lat_per_row = final_lat_range / height as f64;
|
||||
|
||||
// Grid of characters
|
||||
let mut grid = vec![vec![' '; width]; height];
|
||||
|
||||
// Rasterize polygon edges onto the grid
|
||||
for (i, country) in countries.iter().enumerate() {
|
||||
let (c_min_lon, c_min_lat, c_max_lon, c_max_lat) = country.bbox;
|
||||
if c_max_lon < vp_min_lon
|
||||
|| c_min_lon > vp_min_lon + final_lon_range
|
||||
|| c_max_lat < vp_max_lat - final_lat_range
|
||||
|| c_min_lat > vp_max_lat
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let border_ch = if i == target_idx { '#' } else { '\u{00b7}' };
|
||||
|
||||
for poly in &country.polygons {
|
||||
for ring in poly {
|
||||
if ring.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for edge in ring.windows(2) {
|
||||
rasterize_edge(
|
||||
edge[0][0],
|
||||
edge[0][1],
|
||||
edge[1][0],
|
||||
edge[1][1],
|
||||
vp_min_lon,
|
||||
vp_max_lat,
|
||||
lon_per_col,
|
||||
lat_per_row,
|
||||
width,
|
||||
height,
|
||||
border_ch,
|
||||
i == target_idx,
|
||||
&mut grid,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place country code labels at bbox center
|
||||
for (i, country) in countries.iter().enumerate() {
|
||||
if country.iso_a2 == "-99" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (label_lon, label_lat) = country.label_pos;
|
||||
|
||||
let col = ((label_lon - vp_min_lon) / lon_per_col) as isize;
|
||||
let row = ((vp_max_lat - label_lat) / lat_per_row) as isize;
|
||||
|
||||
let label = &country.iso_a2;
|
||||
let start_col = col - (label.len() as isize / 2);
|
||||
|
||||
for (j, ch) in label.chars().enumerate() {
|
||||
let c = start_col + j as isize;
|
||||
if c >= 0 && (c as usize) < width && row >= 0 && (row as usize) < height {
|
||||
let r = row as usize;
|
||||
let c = c as usize;
|
||||
let existing = grid[r][c];
|
||||
// Target label always writes; neighbor labels only on empty or neighbor border
|
||||
if i == target_idx || existing == ' ' || existing == '\u{00b7}' {
|
||||
grid[r][c] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render grid to string
|
||||
grid.iter()
|
||||
.map(|row| row.iter().collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Rasterize a line segment onto the grid using Bresenham's algorithm.
|
||||
fn rasterize_edge(
|
||||
lon0: f64,
|
||||
lat0: f64,
|
||||
lon1: f64,
|
||||
lat1: f64,
|
||||
vp_min_lon: f64,
|
||||
vp_max_lat: f64,
|
||||
lon_per_col: f64,
|
||||
lat_per_row: f64,
|
||||
width: usize,
|
||||
height: usize,
|
||||
ch: char,
|
||||
is_target: bool,
|
||||
grid: &mut [Vec<char>],
|
||||
) {
|
||||
let c0 = ((lon0 - vp_min_lon) / lon_per_col) as i32;
|
||||
let r0 = ((vp_max_lat - lat0) / lat_per_row) as i32;
|
||||
let c1 = ((lon1 - vp_min_lon) / lon_per_col) as i32;
|
||||
let r1 = ((vp_max_lat - lat1) / lat_per_row) as i32;
|
||||
|
||||
let mut x = c0;
|
||||
let mut y = r0;
|
||||
let dx = (c1 - c0).abs();
|
||||
let dy = -(r1 - r0).abs();
|
||||
let sx = if c0 < c1 { 1 } else { -1 };
|
||||
let sy = if r0 < r1 { 1 } else { -1 };
|
||||
let mut err = dx + dy;
|
||||
|
||||
loop {
|
||||
if x >= 0 && (x as usize) < width && y >= 0 && (y as usize) < height {
|
||||
let cell = &mut grid[y as usize][x as usize];
|
||||
// Target borders overwrite neighbor borders; neighbor borders only on empty
|
||||
if is_target || *cell == ' ' {
|
||||
*cell = ch;
|
||||
}
|
||||
}
|
||||
|
||||
if x == c1 && y == r1 {
|
||||
break;
|
||||
}
|
||||
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy {
|
||||
err += dy;
|
||||
x += sx;
|
||||
}
|
||||
if e2 <= dx {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@
|
||||
) (lib.filterAttrs (_: v: v != null)
|
||||
(lib.mapAttrs (n: t: mkTestDrv folder n t) (testDir folder)));
|
||||
|
||||
database = self.packages.${system}."db-tool";
|
||||
database = self.packages.${system}."db-dev";
|
||||
dbOps = self.packages.${system}."db-ops";
|
||||
postgresInit = self.packages.${system}."postgres-init";
|
||||
postgresCleanup = self.packages.${system}."postgres-cleanup";
|
||||
|
||||
@@ -48,7 +49,7 @@
|
||||
mkNonPgTest = testName: testDrv: pkgs.runCommand "db-tool-${testName}"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.gnused ];
|
||||
buildInputs = [ database postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash ];
|
||||
buildInputs = [ database dbOps postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash ];
|
||||
} ''
|
||||
${builtins.readFile self.legacyPackages.${system}.helpers.posix-shell.log}
|
||||
test=${testDrv}
|
||||
@@ -64,7 +65,7 @@
|
||||
mkPgTest = testName: testDrv: pkgs.runCommand "db-tool-${testName}"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.gnused ];
|
||||
buildInputs = [ database postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash pkgs.netcat-openbsd ];
|
||||
buildInputs = [ database dbOps postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash pkgs.netcat-openbsd ];
|
||||
} ''
|
||||
${builtins.readFile self.legacyPackages.${system}.helpers.posix-shell.log}
|
||||
test=${testDrv}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# shellcheck shell=dash
|
||||
|
||||
export HECTIC_NAMESPACE=test-db-ops-help
|
||||
|
||||
log notice "test case: db-ops --help exits 0"
|
||||
if ! db-ops --help > /tmp/db-ops-help-out.txt 2>&1; then
|
||||
log error "test failed: db-ops --help exited non-zero"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tok in secrets load HECTIC_DOTENV_FILE PGURL DB_URL; do
|
||||
if ! grep -qF "$tok" /tmp/db-ops-help-out.txt; then
|
||||
log error "test failed: db-ops --help output missing token: $tok"
|
||||
cat /tmp/db-ops-help-out.txt >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
log notice "test passed"
|
||||
@@ -0,0 +1,24 @@
|
||||
# shellcheck shell=dash
|
||||
|
||||
export HECTIC_NAMESPACE=test-db-ops-missing-dotenv
|
||||
|
||||
if db-ops --url 'postgresql://example.invalid/test' secrets load > /tmp/db-ops-missing-dotenv.out 2>&1; then
|
||||
log error "expected db-ops secrets load to fail without dotenv source"
|
||||
exit 1
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
|
||||
[ "$exit_code" -eq 3 ] || {
|
||||
log error "expected exit code 3 without dotenv source, got: $exit_code"
|
||||
cat /tmp/db-ops-missing-dotenv.out >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! grep -qF 'dotenv source is required' /tmp/db-ops-missing-dotenv.out; then
|
||||
log error "missing dotenv source error message"
|
||||
cat /tmp/db-ops-missing-dotenv.out >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test passed"
|
||||
@@ -0,0 +1,28 @@
|
||||
# shellcheck shell=dash
|
||||
|
||||
export HECTIC_NAMESPACE=test-db-ops-missing-pgurl
|
||||
|
||||
dotenv_file=$(mktemp)
|
||||
trap 'rm -f "$dotenv_file"' EXIT INT TERM
|
||||
printf 'TEST_SECRET=hello-world\n' > "$dotenv_file"
|
||||
|
||||
if db-ops secrets load --dotenv-file "$dotenv_file" > /tmp/db-ops-missing-pgurl.out 2>&1; then
|
||||
log error "expected db-ops secrets load to fail without PGURL"
|
||||
exit 1
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
|
||||
[ "$exit_code" -eq 3 ] || {
|
||||
log error "expected exit code 3 without PGURL, got: $exit_code"
|
||||
cat /tmp/db-ops-missing-pgurl.out >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! grep -qF 'PGURL or DB_URL is required' /tmp/db-ops-missing-pgurl.out; then
|
||||
log error "missing PGURL error message"
|
||||
cat /tmp/db-ops-missing-pgurl.out >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test passed"
|
||||
@@ -0,0 +1,28 @@
|
||||
# shellcheck shell=dash
|
||||
|
||||
export HECTIC_NAMESPACE=test-db-ops-unreadable-dotenv
|
||||
|
||||
dotenv_dir=$(mktemp -d)
|
||||
dotenv_file="$dotenv_dir/missing.env"
|
||||
trap 'rm -rf "$dotenv_dir"' EXIT INT TERM
|
||||
|
||||
if db-ops --url 'postgresql://example.invalid/test' secrets load --dotenv-file "$dotenv_file" > /tmp/db-ops-unreadable-dotenv.out 2>&1; then
|
||||
log error "expected db-ops secrets load to fail for unreadable explicit dotenv path"
|
||||
exit 1
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
|
||||
[ "$exit_code" -eq 3 ] || {
|
||||
log error "expected exit code 3 for unreadable explicit dotenv path, got: $exit_code"
|
||||
cat /tmp/db-ops-unreadable-dotenv.out >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! grep -qF 'dotenv file is not readable' /tmp/db-ops-unreadable-dotenv.out; then
|
||||
log error "missing unreadable dotenv file error message"
|
||||
cat /tmp/db-ops-unreadable-dotenv.out >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test passed"
|
||||
@@ -0,0 +1,56 @@
|
||||
# shellcheck shell=dash
|
||||
|
||||
HECTIC_NAMESPACE=test-db-ops-secrets-load
|
||||
|
||||
PG_WORKING_DIR=$(mktemp -d)
|
||||
export PG_WORKING_DIR PG_DATABASE=testdb PG_PORT=5432 PG_SHARED_PRELOAD_LIBRARIES=''
|
||||
|
||||
cleanup() {
|
||||
postgres-cleanup >/dev/null 2>&1 || :
|
||||
rm -rf "$PG_WORKING_DIR"
|
||||
}
|
||||
trap 'cleanup' EXIT INT TERM
|
||||
|
||||
if ! postgres-init; then
|
||||
log error "postgres-init failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PGURL="postgresql://$(id -un)@/testdb?host=${PG_WORKING_DIR}/sock&port=5432"
|
||||
export PGURL
|
||||
|
||||
dotenv_file=$(mktemp)
|
||||
printf 'TEST_SECRET=hello-world\nQUOTED_SECRET="quoted value"\nDELIMITER_SECRET=before$ps_env$after\n' > "$dotenv_file"
|
||||
|
||||
if ! db-ops secrets load --dotenv-file "$dotenv_file"; then
|
||||
log error "db-ops secrets load failed"
|
||||
rm -f "$dotenv_file"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$dotenv_file"
|
||||
|
||||
secret_value=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT value FROM hectic.secret WHERE key = 'TEST_SECRET';") || exit 1
|
||||
[ "$secret_value" = "hello-world" ] || {
|
||||
log error "expected TEST_SECRET to be loaded, got: $secret_value"
|
||||
exit 1
|
||||
}
|
||||
|
||||
quoted_value=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT value FROM hectic.secret WHERE key = 'QUOTED_SECRET';") || exit 1
|
||||
[ "$quoted_value" = "quoted value" ] || {
|
||||
log error "expected QUOTED_SECRET to strip outer quotes, got: $quoted_value"
|
||||
exit 1
|
||||
}
|
||||
|
||||
delimiter_value=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT value FROM hectic.secret WHERE key = 'DELIMITER_SECRET';") || exit 1
|
||||
[ "$delimiter_value" = 'before$ps_env$after' ] || {
|
||||
log error "expected DELIMITER_SECRET to preserve dollar-quote delimiter text, got: $delimiter_value"
|
||||
exit 1
|
||||
}
|
||||
|
||||
hectic_schema=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT count(*) FROM pg_namespace WHERE nspname='hectic';") || exit 1
|
||||
[ "$hectic_schema" = 1 ] || {
|
||||
log error "expected hectic schema to exist, got: $hectic_schema"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log notice "test passed"
|
||||
@@ -1,6 +1,6 @@
|
||||
log notice "test case: ${WHITE}error: ambiguous command"
|
||||
set +e
|
||||
migrator --inherits tablename --inherits 'table name' list migrate
|
||||
migrator --inherits tablename --inherits 'table name' list migrate >/tmp/migrator-arguments-1.out 2>&1
|
||||
error_code=$?
|
||||
set -e
|
||||
|
||||
@@ -12,9 +12,14 @@ elif [ "$error_code" != 2 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'ambiguous subcommand' /tmp/migrator-arguments-1.out; then
|
||||
log error "test failed: ${WHITE}missing ambiguous subcommand diagnostic"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test case: ${WHITE}error: ambiguous migrate command"
|
||||
set +e
|
||||
migrator --inherits tablename --inherits 'table name' migrate to up
|
||||
migrator --inherits tablename --inherits 'table name' migrate to up >/tmp/migrator-arguments-2.out 2>&1
|
||||
error_code=$?
|
||||
set -e
|
||||
|
||||
@@ -25,3 +30,40 @@ elif [ "$error_code" != 2 ]; then
|
||||
log error "test failed: ${WHITE}unexpected error code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'ambiguous migrate subcommand' /tmp/migrator-arguments-2.out; then
|
||||
log error "test failed: ${WHITE}missing ambiguous migrate diagnostic"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test case: ${WHITE}error: init invalid argument is logged"
|
||||
set +e
|
||||
migrator init --wat >/tmp/migrator-arguments-3.out 2>&1
|
||||
error_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$error_code" != 9 ]; then
|
||||
log error "test failed: ${WHITE}expected exit code 9 for invalid init argument, got $error_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'init argument .*--wat.* does not exists' /tmp/migrator-arguments-3.out; then
|
||||
log error "test failed: ${WHITE}missing init invalid argument diagnostic"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test case: ${WHITE}error: list invalid argument is logged with correct command name"
|
||||
set +e
|
||||
migrator list --wat >/tmp/migrator-arguments-4.out 2>&1
|
||||
error_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$error_code" != 9 ]; then
|
||||
log error "test failed: ${WHITE}expected exit code 9 for invalid list argument, got $error_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'list argument .*--wat.* does not exists' /tmp/migrator-arguments-4.out; then
|
||||
log error "test failed: ${WHITE}missing list invalid argument diagnostic"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -24,6 +24,14 @@ if [ "$answer" -ne 4 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test case: ${WHITE}unguarded assignment under errexit"
|
||||
answer=$(index_of "$array" 'item4')
|
||||
|
||||
if [ "$answer" -ne 4 ]; then
|
||||
log error "test failed: ${WHITE}wrong answer from unguarded assignment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "test case: ${WHITE}error: item not found"
|
||||
if answer=$(index_of "$array" 'item10'); then
|
||||
log error "test failed: ${WHITE} must return an error"
|
||||
|
||||
Reference in New Issue
Block a user