Compare commits
5 Commits
01f47b419d
...
863dce5048
| Author | SHA1 | Date | |
|---|---|---|---|
| 863dce5048 | |||
| b7ce5deb4c | |||
| 889a123231 | |||
| c159115847 | |||
| 3538297991 |
@@ -66,6 +66,45 @@
|
||||
|
||||
registry.address.pikachu.self = "10.23.10.102";
|
||||
|
||||
modules.services.steamcmd = {
|
||||
enable = true;
|
||||
dataDir = "/pikachu/data/steamcmd";
|
||||
apps.valheim = {
|
||||
enable = true;
|
||||
appId = 896660;
|
||||
installDir = "/pikachu/data/servers/valheim/main";
|
||||
workingDirectory = "/pikachu/data/servers/valheim/main";
|
||||
dataDirs = [ "/pikachu/media/games/servers/valheim/main" ];
|
||||
packages = [ pkgs.steam-run ];
|
||||
environment = {
|
||||
SteamAppId = "892970";
|
||||
SERVER_PASSWORD = "foobarbaz";
|
||||
};
|
||||
command = [
|
||||
"${pkgs.bash}/bin/bash"
|
||||
"-c"
|
||||
''
|
||||
${pkgs.coreutils}/bin/install -d -m 0770 /pikachu/media/games/servers/valheim/main
|
||||
export LD_LIBRARY_PATH="$PWD/linux64:$LD_LIBRARY_PATH"
|
||||
exec steam-run ./valheim_server.x86_64 \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-name main \
|
||||
-port 2456 \
|
||||
-world main \
|
||||
-savedir /pikachu/media/games/servers/valheim/main \
|
||||
-password "$SERVER_PASSWORD" \
|
||||
-public 0
|
||||
''
|
||||
];
|
||||
udpPorts = [
|
||||
2456
|
||||
2457
|
||||
2458
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
guests.actualbudget = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
|
||||
325
configs/nixos/modules/services/steamcmd.nix
Normal file
325
configs/nixos/modules/services/steamcmd.nix
Normal file
@@ -0,0 +1,325 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.steamcmd;
|
||||
|
||||
appType = lib.types.submodule (
|
||||
{ config, name, ... }:
|
||||
{
|
||||
options = {
|
||||
enable = lib.mkEnableOption "this SteamCMD app service";
|
||||
|
||||
appId = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
example = 896660;
|
||||
description = "Steam app ID for the dedicated server.";
|
||||
};
|
||||
|
||||
installDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/servers/valheim/main";
|
||||
description = "Directory where SteamCMD installs the server files.";
|
||||
};
|
||||
|
||||
workingDirectory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/data/servers/valheim/main";
|
||||
description = "Directory where the game service starts.";
|
||||
};
|
||||
|
||||
command = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
example = [
|
||||
"/data/servers/valheim/main/valheim_server.x86_64"
|
||||
"-name"
|
||||
"main"
|
||||
];
|
||||
description = "Command that starts the game server.";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = cfg.user;
|
||||
defaultText = lib.literalExpression "config.modules.services.steamcmd.user";
|
||||
description = "User account that runs the game service.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = cfg.group;
|
||||
defaultText = lib.literalExpression "config.modules.services.steamcmd.group";
|
||||
description = "Group account that owns writable server files.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
description = "Environment variables for the game service.";
|
||||
};
|
||||
|
||||
environmentFiles = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.path;
|
||||
default = [ ];
|
||||
description = "Environment files loaded by the game service.";
|
||||
};
|
||||
|
||||
dataDirs = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "/media/games/servers/valheim/main" ];
|
||||
description = "Additional writable directories created before the game service starts.";
|
||||
};
|
||||
|
||||
updateOnStart = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Runs SteamCMD before each game service start.";
|
||||
};
|
||||
|
||||
validateUpdate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Passes validate to SteamCMD app_update.";
|
||||
};
|
||||
|
||||
packages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
default = [ ];
|
||||
description = "Packages added to the game service path.";
|
||||
};
|
||||
|
||||
tcpPorts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.port;
|
||||
default = [ ];
|
||||
description = "TCP ports opened for the game service.";
|
||||
};
|
||||
|
||||
udpPorts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.port;
|
||||
default = [ ];
|
||||
description = "UDP ports opened for the game service.";
|
||||
};
|
||||
|
||||
restart = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enables the scheduled restart timer.";
|
||||
};
|
||||
|
||||
onCalendar = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "daily";
|
||||
example = "*-*-* 05:00:00";
|
||||
description = "Calendar expression for the restart timer.";
|
||||
};
|
||||
|
||||
randomizedDelaySec = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "15m";
|
||||
description = "Maximum randomized delay for the restart timer.";
|
||||
};
|
||||
|
||||
command = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "systemctl restart valheim.service";
|
||||
description = "Command run by the restart timer.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
workingDirectory = lib.mkDefault config.installDir;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
enabledApps = lib.filterAttrs (_: app: app.enable) cfg.apps;
|
||||
|
||||
prepareScript = name: app:
|
||||
pkgs.writeShellScript "steamcmd-${name}-prepare" ''
|
||||
set -eu
|
||||
|
||||
${pkgs.coreutils}/bin/install -d \
|
||||
-o ${lib.escapeShellArg cfg.user} \
|
||||
-g ${lib.escapeShellArg cfg.group} \
|
||||
-m 0770 \
|
||||
${lib.escapeShellArg cfg.dataDir}
|
||||
|
||||
${pkgs.coreutils}/bin/install -d \
|
||||
-o ${lib.escapeShellArg app.user} \
|
||||
-g ${lib.escapeShellArg app.group} \
|
||||
-m 0770 \
|
||||
${lib.escapeShellArg app.installDir} \
|
||||
${lib.escapeShellArgs app.dataDirs}
|
||||
'';
|
||||
|
||||
updateScript = name: app:
|
||||
pkgs.writeShellScript "steamcmd-${name}-update" ''
|
||||
set -eu
|
||||
|
||||
export HOME=${lib.escapeShellArg cfg.dataDir}
|
||||
exec ${lib.getExe cfg.package} \
|
||||
+force_install_dir ${lib.escapeShellArg app.installDir} \
|
||||
+login anonymous \
|
||||
+app_update ${toString app.appId} ${lib.optionalString app.validateUpdate "validate"} \
|
||||
+quit
|
||||
'';
|
||||
|
||||
startScript = name: app:
|
||||
pkgs.writeShellScript "steamcmd-${name}-start" ''
|
||||
set -eu
|
||||
|
||||
cd ${lib.escapeShellArg app.workingDirectory}
|
||||
exec ${lib.escapeShellArgs app.command}
|
||||
'';
|
||||
|
||||
restartCommand = name: app:
|
||||
if app.restart.command != null then
|
||||
app.restart.command
|
||||
else
|
||||
"systemctl restart ${lib.escapeShellArg name}.service";
|
||||
in
|
||||
{
|
||||
options.modules.services.steamcmd = {
|
||||
enable = lib.mkEnableOption "SteamCMD service management";
|
||||
|
||||
package = lib.mkPackageOption pkgs "steamcmd" { };
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "steamcmd";
|
||||
description = "User account that runs SteamCMD.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "steamcmd";
|
||||
description = "Group account that owns SteamCMD files.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/data/steamcmd";
|
||||
description = "Directory where SteamCMD stores metadata and cache files.";
|
||||
};
|
||||
|
||||
apps = lib.mkOption {
|
||||
type = lib.types.attrsOf appType;
|
||||
default = { };
|
||||
description = "SteamCMD-managed game services keyed by service name.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
registry."stable-uid".${config.networking.hostName}.steamcmd = 20003;
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = enabledApps != { };
|
||||
message = "modules.services.steamcmd.apps must contain at least one enabled app.";
|
||||
}
|
||||
];
|
||||
|
||||
users.groups.${cfg.group} = { };
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
uid = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules =
|
||||
[
|
||||
"d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
|
||||
"z ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
|
||||
]
|
||||
++ lib.flatten (
|
||||
lib.mapAttrsToList (_: app: [
|
||||
"d ${app.installDir} 0770 ${cfg.user} ${cfg.group} - -"
|
||||
"z ${app.installDir} 0770 ${cfg.user} ${cfg.group} - -"
|
||||
]) enabledApps
|
||||
);
|
||||
|
||||
systemd.services = lib.mkMerge (
|
||||
lib.mapAttrsToList (name: app: {
|
||||
"steamcmd-${name}-update" = {
|
||||
description = "Update ${name} with SteamCMD";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = app.user;
|
||||
Group = app.group;
|
||||
WorkingDirectory = "-${cfg.dataDir}";
|
||||
ExecStartPre = "+${prepareScript name app}";
|
||||
ExecStart = updateScript name app;
|
||||
};
|
||||
};
|
||||
|
||||
${name} = {
|
||||
description = "${name} game server";
|
||||
after = [
|
||||
"network-online.target"
|
||||
"steamcmd-${name}-update.service"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = app.packages;
|
||||
environment = app.environment;
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = app.user;
|
||||
Group = app.group;
|
||||
WorkingDirectory = "-${app.workingDirectory}";
|
||||
EnvironmentFile = app.environmentFiles;
|
||||
ExecStartPre = [
|
||||
"+${prepareScript name app}"
|
||||
] ++ lib.optional app.updateOnStart (updateScript name app);
|
||||
ExecStart = startScript name app;
|
||||
Restart = "on-failure";
|
||||
};
|
||||
};
|
||||
|
||||
"steamcmd-${name}-restart" = lib.mkIf app.restart.enable {
|
||||
description = "Restart ${name}";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "steamcmd-${name}-restart" ''
|
||||
set -eu
|
||||
|
||||
exec ${restartCommand name app}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}) enabledApps
|
||||
);
|
||||
|
||||
systemd.timers = lib.mkMerge (
|
||||
lib.mapAttrsToList (name: app: {
|
||||
"steamcmd-${name}-restart" = lib.mkIf app.restart.enable {
|
||||
description = "Restart ${name} on a schedule";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = app.restart.onCalendar;
|
||||
RandomizedDelaySec = app.restart.randomizedDelaySec;
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
}) enabledApps
|
||||
);
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.tcpPorts) enabledApps);
|
||||
networking.firewall.allowedUDPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.udpPorts) enabledApps);
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -5,31 +5,51 @@ end
|
||||
|
||||
set host $argv[1]
|
||||
set branch $argv[2]
|
||||
set branch_source explicit
|
||||
|
||||
if test -z "$branch"
|
||||
set branch_source current
|
||||
set branch (git branch --show-current)
|
||||
end
|
||||
|
||||
if test -z "$branch"
|
||||
echo "deploy-to: could not determine the current branch" >&2
|
||||
return 2
|
||||
end
|
||||
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||
or begin
|
||||
echo "deploy-to: run this from a git checkout" >&2
|
||||
return 2
|
||||
end
|
||||
|
||||
set dirty_lines (git status --porcelain)
|
||||
if test (count $dirty_lines) -gt 0
|
||||
echo "deploy-to: refusing to deploy with a dirty local worktree" >&2
|
||||
set untracked_lines (git status --porcelain --untracked-files=normal | string match -r '^\?\? ')
|
||||
if test (count $untracked_lines) -gt 0
|
||||
echo "deploy-to: refusing to deploy untracked files" >&2
|
||||
echo "deploy-to: git add new files that Nix must see, or remove them" >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
git fetch origin
|
||||
or return $status
|
||||
|
||||
if test -z "$branch"; or not git rev-parse --verify --quiet refs/remotes/origin/$branch >/dev/null
|
||||
if test "$branch_source" = explicit
|
||||
echo "deploy-to: origin/$branch does not exist" >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
set branch_source upstream
|
||||
set upstream_ref (git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)
|
||||
set branch (string replace -r '^origin/' '' -- $upstream_ref)
|
||||
end
|
||||
|
||||
if test -z "$branch"; or not git rev-parse --verify --quiet refs/remotes/origin/$branch >/dev/null
|
||||
set branch_source default
|
||||
set origin_head (git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
|
||||
set branch (string replace -r '^origin/' '' -- $origin_head)
|
||||
end
|
||||
|
||||
if test -z "$branch"
|
||||
echo "deploy-to: could not determine a deploy branch" >&2
|
||||
return 2
|
||||
end
|
||||
|
||||
set remote_ref refs/remotes/origin/$branch
|
||||
git rev-parse --verify --quiet $remote_ref >/dev/null
|
||||
or begin
|
||||
@@ -37,17 +57,37 @@ or begin
|
||||
return 1
|
||||
end
|
||||
|
||||
set local_head (git rev-parse HEAD)
|
||||
set remote_head (git rev-parse $remote_ref)
|
||||
|
||||
if test "$local_head" != "$remote_head"
|
||||
echo "deploy-to: local HEAD differs from origin/$branch" >&2
|
||||
echo "deploy-to: push or reset the branch before deploying" >&2
|
||||
return 1
|
||||
set has_local_changes false
|
||||
if not git diff --quiet $remote_ref --
|
||||
set has_local_changes true
|
||||
end
|
||||
|
||||
set escaped_branch (string escape -- $branch)
|
||||
set escaped_host (string escape -- $host)
|
||||
set remote_command "cd ~/dev && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
|
||||
set remote_command "cd ~/dev && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && git clean -fd"
|
||||
|
||||
set patch_file ""
|
||||
set remote_patch_file ""
|
||||
if test "$has_local_changes" = true
|
||||
set patch_file (mktemp)
|
||||
set remote_patch_file "/tmp/deploy-to-$host.patch"
|
||||
git diff --binary $remote_ref -- >$patch_file
|
||||
or begin
|
||||
rm -f $patch_file
|
||||
return $status
|
||||
end
|
||||
|
||||
scp $patch_file $host:$remote_patch_file
|
||||
set scp_status $status
|
||||
rm -f $patch_file
|
||||
if test $scp_status -ne 0
|
||||
return $scp_status
|
||||
end
|
||||
|
||||
set escaped_remote_patch_file (string escape -- $remote_patch_file)
|
||||
set remote_command "$remote_command && git apply --index $escaped_remote_patch_file && rm -f $escaped_remote_patch_file"
|
||||
end
|
||||
|
||||
set remote_command "$remote_command && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
|
||||
|
||||
ssh -t $host $remote_command
|
||||
|
||||
93
docs/00 INBOX/ai/steamcmd-server.md
Normal file
93
docs/00 INBOX/ai/steamcmd-server.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Add a SteamCMD server
|
||||
|
||||
## 1. Configure the generic SteamCMD module
|
||||
|
||||
1. Create or update `configs/nixos/modules/services/steamcmd.nix`.
|
||||
2. Define `modules.services.steamcmd.enable`.
|
||||
3. Create the `steamcmd` service user.
|
||||
4. Configure the shared SteamCMD data directory.
|
||||
5. Add one option for each Steam app service.
|
||||
6. Run SteamCMD before each app service starts.
|
||||
7. Update the Steam app on each service restart.
|
||||
8. Add a restart timer with default settings.
|
||||
9. Add an option that overrides the restart command.
|
||||
10. Open declared in-machine firewall ports.
|
||||
|
||||
## 2. Configure the game service module
|
||||
|
||||
1. Create or update `configs/nixos/modules/services/<game>.nix`.
|
||||
2. Define `modules.services.<game>.enable`.
|
||||
3. Add the Steam app ID for the dedicated server.
|
||||
4. Add runtime packages that the server binary needs.
|
||||
5. Add `serverName` for the service identity.
|
||||
6. Use `serverName` as the default world or save name.
|
||||
7. Add a game-specific world or save option when the game supports it.
|
||||
8. Add guest-internal paths for server files and durable state.
|
||||
9. Add options for required ports.
|
||||
10. Add options for required secret files or values.
|
||||
11. Map the game options to `modules.services.steamcmd`.
|
||||
12. Configure the game service command.
|
||||
13. Pass secrets to the service without embedding secret values in Nix.
|
||||
14. Open the required in-machine firewall ports.
|
||||
|
||||
## 3. Configure the guest module
|
||||
|
||||
1. Create or update `configs/nixos/guests/<game>.nix`.
|
||||
2. Define `guests.<game>` options for host-level wiring.
|
||||
3. Add `serverName`.
|
||||
4. Pass `serverName` to `modules.services.<game>` inside `interior`.
|
||||
5. Add host path options for SteamCMD data, server files, and durable state.
|
||||
6. Use names such as `steamcmdPath`, `serverPath`, and `statePath`.
|
||||
7. Mount each host path into the guest.
|
||||
8. Mount sops-backed secret files read-only into the guest.
|
||||
9. Pass guest paths for secret files to `modules.services.<game>`.
|
||||
10. Set guest resource limits.
|
||||
11. Keep VLAN placement and address wiring in the guest or host configuration.
|
||||
|
||||
## 4. Add host storage
|
||||
|
||||
1. Choose a host path for SteamCMD data.
|
||||
2. Choose a host path for downloaded server files.
|
||||
3. Choose a host path for world data, saves, and generated config.
|
||||
4. Configure those paths through `guests.<game>`.
|
||||
|
||||
## 5. Add secrets
|
||||
|
||||
1. Add a sops secret for each durable server secret.
|
||||
2. Configure the guest option that names each sops secret.
|
||||
3. Do not put passwords or tokens in Nix strings.
|
||||
4. Do not pass passwords or tokens as process arguments.
|
||||
|
||||
## 6. Configure networking
|
||||
|
||||
1. Keep game port knowledge in `modules.services.<game>`.
|
||||
2. Open game ports from `modules.services.<game>`.
|
||||
3. Configure guest VLAN placement from the guest or host configuration.
|
||||
4. Add a DHCP reservation before you commit a stable guest address.
|
||||
5. Forward public ports to the guest address when the server needs public access.
|
||||
|
||||
## 7. Enable the guest on the target host
|
||||
|
||||
1. Set `guests.<game>.enable = true`.
|
||||
2. Set `guests.<game>.serverName` to the server identity.
|
||||
3. Set the host paths for SteamCMD data, server files, and durable state.
|
||||
4. Set the secret names for the server.
|
||||
5. Set the guest network options required by the host.
|
||||
|
||||
## 8. Deploy the host
|
||||
|
||||
1. Run `deploy-to <host>` from a clean checkout.
|
||||
2. Check `container@<game>.service` on the host.
|
||||
3. Enter the guest with `nixos-container root-login <game>`.
|
||||
4. Check the SteamCMD update service.
|
||||
5. Check the game service with `systemctl status <unit>`.
|
||||
6. Follow logs with `journalctl -u <unit> -f`.
|
||||
|
||||
## 9. Validate the server
|
||||
|
||||
1. Confirm the guest address with `machinectl status <game>`.
|
||||
2. Connect from the LAN before you add public forwarding.
|
||||
3. Restart the game service.
|
||||
4. Confirm that the restart updates the Steam app.
|
||||
5. Confirm that the restart timer uses the expected command.
|
||||
6. Confirm that durable state lands in the mounted state path.
|
||||
@@ -3,13 +3,19 @@ load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library")
|
||||
rust_library(
|
||||
name = "ariadne_lib",
|
||||
crate_name = "ariadne",
|
||||
srcs = ["src/lib.rs"],
|
||||
srcs = [
|
||||
"lib.rs",
|
||||
"pipeline.rs",
|
||||
],
|
||||
)
|
||||
|
||||
rust_binary(
|
||||
name = "ariadne",
|
||||
crate_name = "ariadne_cli",
|
||||
srcs = ["src/main.rs"],
|
||||
deps = [":ariadne_lib"],
|
||||
srcs = ["main.rs"],
|
||||
deps = [
|
||||
":ariadne_lib",
|
||||
"//third_party/crates:clap",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@ name = "ariadne"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ariadne"
|
||||
path = "main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
# Ariadne
|
||||
|
||||
Ariadne analyzes Clew output for a local Ren'Py game install.
|
||||
|
||||
Keep project-owned source under `src/ariadne/`.
|
||||
Keep game-derived files local and ignored under `src/ariadne/output/`.
|
||||
Ariadne analyzes Clew-produced data for local Ren'Py projects.
|
||||
|
||||
Clew owns extraction, preparation, and Ren'Py AST export.
|
||||
Ariadne consumes Clew's `clew-renpy-ast-v1` JSON from `output/ast/`.
|
||||
Ariadne maps that data into internal analysis state.
|
||||
Ariadne consumes Clew output and builds analysis artifacts from it.
|
||||
Keep project-owned source under `src/ariadne/`; keep generated or game-derived files out of Git.
|
||||
|
||||
## Bazel shape
|
||||
## CLI
|
||||
|
||||
Run the current placeholder CLI with:
|
||||
Run the default target, `all`:
|
||||
|
||||
```sh
|
||||
bazel run //src/ariadne:ariadne
|
||||
```
|
||||
|
||||
By default, Ariadne writes output under `output/`.
|
||||
Use `--output-dir` to choose a different output directory:
|
||||
|
||||
```sh
|
||||
bazel run //src/ariadne:ariadne -- --output-dir scratch/ariadne-output
|
||||
```
|
||||
|
||||
Run through a specific target stage and its prerequisites:
|
||||
|
||||
```sh
|
||||
bazel run //src/ariadne:ariadne -- parse
|
||||
bazel run //src/ariadne:ariadne -- add-jump-edges
|
||||
```
|
||||
|
||||
Available target stages:
|
||||
|
||||
- `all`
|
||||
- `parse`
|
||||
- `add-implicit-edges`
|
||||
- `add-init-edges`
|
||||
- `add-jump-edges`
|
||||
|
||||
Relative `--output-dir` paths resolve from the workspace root when Ariadne runs through `bazel run`.
|
||||
|
||||
## Pipeline skeleton
|
||||
|
||||
Ariadne currently has pipeline scaffolding only.
|
||||
Each executable stage prepares an output directory at:
|
||||
|
||||
```text
|
||||
<output-dir>/graph/<stage>/
|
||||
```
|
||||
|
||||
If a stage output directory already exists and is not empty, Ariadne fails unless `--force` is set.
|
||||
With `--force`, Ariadne removes the existing stage output directory before recreating it.
|
||||
|
||||
Stage implementations are placeholders and do not write graph artifacts yet.
|
||||
|
||||
59
src/ariadne/lib.rs
Normal file
59
src/ariadne/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::pipeline::Stage;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
const OUTPUT_GRAPH_DIR: &str = "graph";
|
||||
|
||||
pub type Result<T> = io::Result<T>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub output_dir: PathBuf,
|
||||
pub force: bool,
|
||||
pub target_stage: pipeline::Stage,
|
||||
}
|
||||
|
||||
pub fn run(config: &Config) -> Result<()> {
|
||||
for stage in config.target_stage.with_prerequisites() {
|
||||
run_stage(config, stage)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_output_dir(config: &Config, stage: Stage) -> Result<PathBuf> {
|
||||
let output_dir = config.output_dir.join(OUTPUT_GRAPH_DIR).join(stage.name());
|
||||
|
||||
if output_dir.exists() && !output_dir.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotADirectory,
|
||||
format!("output path is not a directory: {path}", path = output_dir.display()),
|
||||
));
|
||||
}
|
||||
|
||||
if output_dir.is_dir() && fs::read_dir(&output_dir)?.next().transpose()?.is_some() {
|
||||
if !config.force {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!(
|
||||
"output directory already exists and is not empty: {path}",
|
||||
path = output_dir.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&output_dir)?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
Ok(output_dir)
|
||||
}
|
||||
|
||||
fn run_stage(config: &Config, stage: Stage) -> Result<()> {
|
||||
let output_dir = get_output_dir(config, stage)?;
|
||||
pipeline::run_stage(stage, &output_dir)
|
||||
}
|
||||
51
src/ariadne/main.rs
Normal file
51
src/ariadne/main.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "output")]
|
||||
output_dir: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
#[arg(default_value = "all", value_parser = parse_stage)]
|
||||
target_stage: ariadne::pipeline::Stage,
|
||||
}
|
||||
|
||||
impl From<Args> for ariadne::Config {
|
||||
fn from(args: Args) -> Self {
|
||||
ariadne::Config {
|
||||
output_dir: resolve_workspace_path(args.output_dir),
|
||||
force: args.force,
|
||||
target_stage: args.target_stage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_stage(value: &str) -> Result<ariadne::pipeline::Stage, String> {
|
||||
ariadne::pipeline::Stage::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|stage| stage.name() == value)
|
||||
.ok_or_else(|| format!("unknown stage: {value}"))
|
||||
}
|
||||
|
||||
// TODO: Move this into a shared Rust utility crate.
|
||||
fn resolve_workspace_path(path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
return path;
|
||||
}
|
||||
|
||||
if let Some(workspace) = std::env::var_os("BUILD_WORKSPACE_DIRECTORY") {
|
||||
return PathBuf::from(workspace).join(path);
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
fn main() -> ariadne::Result<()> {
|
||||
let config: ariadne::Config = Args::parse().into();
|
||||
ariadne::run(&config)
|
||||
}
|
||||
101
src/ariadne/pipeline.rs
Normal file
101
src/ariadne/pipeline.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::Result;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)]
|
||||
pub enum Stage {
|
||||
#[default]
|
||||
All,
|
||||
|
||||
Parse,
|
||||
AddImplicitEdges,
|
||||
AddInitEdges,
|
||||
AddJumpEdges,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
pub const ALL: &'static [Stage] = &[
|
||||
Stage::All,
|
||||
Stage::Parse,
|
||||
Stage::AddImplicitEdges,
|
||||
Stage::AddInitEdges,
|
||||
Stage::AddJumpEdges,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Stage::All => "all",
|
||||
Stage::Parse => "parse",
|
||||
Stage::AddImplicitEdges => "add-implicit-edges",
|
||||
Stage::AddInitEdges => "add-init-edges",
|
||||
Stage::AddJumpEdges => "add-jump-edges",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prerequisites(self) -> &'static [Stage] {
|
||||
match self {
|
||||
Stage::All => &[
|
||||
Stage::AddImplicitEdges,
|
||||
Stage::AddInitEdges,
|
||||
Stage::AddJumpEdges,
|
||||
],
|
||||
Stage::Parse => &[],
|
||||
Stage::AddImplicitEdges => &[Stage::Parse],
|
||||
Stage::AddInitEdges => &[Stage::Parse],
|
||||
Stage::AddJumpEdges => &[Stage::Parse],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prerequisites(self) -> Vec<Stage> {
|
||||
self.with_prerequisites_impl(BTreeSet::new())
|
||||
}
|
||||
|
||||
fn with_prerequisites_impl(self, seen: BTreeSet<Stage>) -> Vec<Stage> {
|
||||
if seen.contains(&self) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut seen = seen;
|
||||
seen.insert(self);
|
||||
|
||||
let mut stages = Vec::new();
|
||||
for prerequisite in self.prerequisites() {
|
||||
let prerequisite_stages = prerequisite.with_prerequisites_impl(seen.clone());
|
||||
seen.extend(prerequisite_stages.iter().copied());
|
||||
stages.extend(prerequisite_stages);
|
||||
}
|
||||
|
||||
if self != Stage::All {
|
||||
stages.push(self);
|
||||
}
|
||||
|
||||
stages
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_stage(stage: Stage, output_dir: &Path) -> Result<()> {
|
||||
match stage {
|
||||
Stage::All => Ok(()),
|
||||
Stage::Parse => run_parse(output_dir),
|
||||
Stage::AddImplicitEdges => run_add_implicit_edges(output_dir),
|
||||
Stage::AddInitEdges => run_add_init_edges(output_dir),
|
||||
Stage::AddJumpEdges => run_add_jump_edges(output_dir),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_parse(_output_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_add_implicit_edges(_output_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_add_init_edges(_output_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_add_jump_edges(_output_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub fn run() {
|
||||
println!("Ariadne");
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
ariadne::run();
|
||||
}
|
||||
Reference in New Issue
Block a user