fix(#2860): make hyperhive.forge.url nullable instead of guessing a URL
The option had a `http://localhost:3000` default, which is only ever correct when the forge shares the caller's network namespace — inside an agent's netns `localhost` is the agent, and the forge may well be on another host. Making it *required* instead was worse: the flake's own container configs are what hive-c0re extends per agent, so the value they needed in order to evaluate became a second definition on every agent and collided with the real one. `null` resolves both. It is not a URL, so nothing can quietly talk to the wrong machine, and it needs no placeholder anywhere: the bases evaluate as they are, so nothing deployment-shaped sits on the config agents inherit from. The units that would consume the URL — tea-login and forge-avatar-sync — are simply not generated without one, making an absent forge an absent integration rather than a misdirected one. hive-forge-notify is unaffected: it reads HIVE_FORGE_URL from the forwarded global environment, not from this option. Verified: agent-base/ruth evaluate with forge.url = null, zero failing assertions bare base: tea_login_present = false, avatar_present = false, notify_present = true extended with a rendered URL: FORGE_URL=http://forge.real.test Refs #2860
This commit is contained in:
parent
2c51fb70d0
commit
bcb9e837f7
2 changed files with 85 additions and 84 deletions
34
flake.nix
34
flake.nix
|
|
@ -149,34 +149,20 @@
|
|||
|
||||
nixosConfigurations =
|
||||
let
|
||||
# Values the agent modules require but that only a real
|
||||
# deployment can know. Real containers are built from the
|
||||
# generated meta flake, where hive-c0re renders these per
|
||||
# agent from the host's `HIVE_FORGE_URL` (see meta.rs's
|
||||
# `SERVICE_URL_OPTIONS`) — they never evaluate through
|
||||
# `self.nixosConfigurations`, so nothing here can reach a
|
||||
# running agent. These two configs exist only to typecheck
|
||||
# the modules and to pre-build the container closure
|
||||
# (`system.extraDependencies`, see hive-c0re/default.nix).
|
||||
#
|
||||
# Deliberately a `.invalid` host (RFC 2606: guaranteed not to
|
||||
# resolve) rather than something plausible like a loopback
|
||||
# port. If this value ever *did* escape into a runtime path,
|
||||
# it must fail loudly at DNS instead of quietly connecting to
|
||||
# whatever happens to be listening — which is the entire
|
||||
# point of removing the `http://localhost:3000` default this
|
||||
# replaces.
|
||||
evalOnlyPlaceholders = {
|
||||
hyperhive.forge.url = "http://forge.invalid";
|
||||
};
|
||||
# These two configs are what hive-c0re extends per agent
|
||||
# (meta.rs's `mkAgent` does `<base>.extendModules { … }`, so
|
||||
# the built base and every container stay on one version).
|
||||
# Nothing deployment-specific may be set here: it would be
|
||||
# inherited by every container on the hive and collide with
|
||||
# the per-agent values hive-c0re renders. The agent modules
|
||||
# are written so a bare evaluation needs no such values —
|
||||
# service URLs default to `null`, meaning "not configured",
|
||||
# and the units that would use them simply aren't generated.
|
||||
mkContainer =
|
||||
module:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
module
|
||||
evalOnlyPlaceholders
|
||||
];
|
||||
modules = [ module ];
|
||||
};
|
||||
in
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ let
|
|||
in
|
||||
{
|
||||
options.hyperhive.forge.url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "http://forge.internal:3000";
|
||||
description = ''
|
||||
Base URL of the hyperhive-managed Forgejo. Used at container
|
||||
|
|
@ -31,28 +32,38 @@ in
|
|||
forge-token file is missing (i.e. hive-forge isn't running on
|
||||
the host).
|
||||
|
||||
**Required, deliberately undefaulted.** hive-c0re renders it into
|
||||
every agent's config from the host's `HIVE_FORGE_URL`, which
|
||||
`hive-c0re.nix` sets unconditionally --- the forge is mandatory.
|
||||
A loopback default would be a guess: the forge may run on a
|
||||
different host from the agents, and inside an agent's network
|
||||
namespace `localhost` reaches the agent, not the forge. An
|
||||
unevaluatable config is better than one that builds and then
|
||||
talks to the wrong machine.
|
||||
**`null` means "no forge", not "guess one".** There is deliberately
|
||||
no loopback default: the forge may run on a different host from
|
||||
the agents, and inside an agent's network namespace `localhost`
|
||||
reaches the agent rather than the forge, so a default would be a
|
||||
value that builds fine and then talks to the wrong machine.
|
||||
When this is `null` the tea-login and avatar-sync units are not
|
||||
generated at all --- an absent integration, never a misdirected
|
||||
one.
|
||||
|
||||
On a real hive it is always set: hive-c0re renders it into every
|
||||
agent's config from the host's `HIVE_FORGE_URL` (which
|
||||
`hive-c0re.nix` sets unconditionally) and refuses to render a meta
|
||||
flake without it, so `null` only survives where the modules are
|
||||
evaluated outside a hive --- exactly the case that has no forge.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# The empty string is the one value the type permits that cannot
|
||||
# be a URL, and it is what a caller supplies when they have
|
||||
# nothing --- exactly the case the removed loopback default used
|
||||
# to paper over. Reject it here so the failure names the option.
|
||||
# Only a *set* value is constrained. `null` is the legitimate
|
||||
# "no forge here" state (see the option doc) and is handled by
|
||||
# not generating the units below, so it must not trip this.
|
||||
# The empty string, by contrast, is the one non-null value the
|
||||
# type permits that cannot be a URL --- it is what a caller
|
||||
# supplies when they have nothing, which is precisely what `null`
|
||||
# is for, so reject it and name the option.
|
||||
{
|
||||
assertion =
|
||||
lib.hasPrefix "http://" config.hyperhive.forge.url
|
||||
config.hyperhive.forge.url == null
|
||||
|| lib.hasPrefix "http://" config.hyperhive.forge.url
|
||||
|| lib.hasPrefix "https://" config.hyperhive.forge.url;
|
||||
message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")";
|
||||
message = "hyperhive.forge.url must be an http:// or https:// URL, or null for no forge (got: \"${toString config.hyperhive.forge.url}\")";
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -105,7 +116,9 @@ in
|
|||
# One-shot: tea config.yml from the seeded forge token. Shape
|
||||
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||||
# docs/conventions.md::Best-effort oneshot services.
|
||||
systemd.services.tea-login = {
|
||||
# Not generated at all when no forge is configured: an absent
|
||||
# integration rather than one pointed at a guessed address.
|
||||
systemd.services.tea-login = lib.mkIf (config.hyperhive.forge.url != null) {
|
||||
description = "configure tea CLI from hive-forge token (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "local-fs.target" ];
|
||||
|
|
@ -188,49 +201,51 @@ in
|
|||
# avatar sync), so the unit only exists when an icon is configured
|
||||
# and needs no librsvg at runtime — Forgejo's Go image library
|
||||
# can't decode SVG, hence PNG.
|
||||
systemd.services.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) {
|
||||
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "tea-login.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "forge-avatar-sync";
|
||||
};
|
||||
path = [
|
||||
pkgs.curl
|
||||
pkgs.coreutils
|
||||
pkgs.jq
|
||||
];
|
||||
script = ''
|
||||
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||||
# (systemd.globalEnvironment) to `/agents/<name>/state`.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "forge-avatar-sync: no forge-token found; skipping"
|
||||
exit 0
|
||||
fi
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
IMAGE=$(base64 -w 0 < ${iconPng})
|
||||
# Forgejo POST /user/avatar expects {"image":"<base64>"} — just the
|
||||
# raw base64 string, NOT a data URI (data:image/png;base64,...).
|
||||
# Use jq to build the payload so the large base64 value is safely quoted.
|
||||
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
|
||||
RESP=$(curl -sf --max-time 10 \
|
||||
-X POST "$FORGE_URL/api/v1/user/avatar" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
-w "\n%{http_code}" 2>/dev/null || true)
|
||||
CODE=$(printf '%s' "$RESP" | tail -1)
|
||||
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
|
||||
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
|
||||
else
|
||||
echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
systemd.services.forge-avatar-sync =
|
||||
lib.mkIf (config.hyperhive.icon != null && config.hyperhive.forge.url != null)
|
||||
{
|
||||
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "tea-login.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "forge-avatar-sync";
|
||||
};
|
||||
path = [
|
||||
pkgs.curl
|
||||
pkgs.coreutils
|
||||
pkgs.jq
|
||||
];
|
||||
script = ''
|
||||
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||||
# (systemd.globalEnvironment) to `/agents/<name>/state`.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "forge-avatar-sync: no forge-token found; skipping"
|
||||
exit 0
|
||||
fi
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
IMAGE=$(base64 -w 0 < ${iconPng})
|
||||
# Forgejo POST /user/avatar expects {"image":"<base64>"} — just the
|
||||
# raw base64 string, NOT a data URI (data:image/png;base64,...).
|
||||
# Use jq to build the payload so the large base64 value is safely quoted.
|
||||
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
|
||||
RESP=$(curl -sf --max-time 10 \
|
||||
-X POST "$FORGE_URL/api/v1/user/avatar" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
-w "\n%{http_code}" 2>/dev/null || true)
|
||||
CODE=$(printf '%s' "$RESP" | tail -1)
|
||||
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
|
||||
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
|
||||
else
|
||||
echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue