Add InstallScriptBuilder + GET /install.sh + GET /client.tar.gz
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class InstallEndpointsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Install_sh_returns_shell_script_with_request_host()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var res = await client.GetAsync("/install.sh");
|
||||||
|
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
res.Content.Headers.ContentType!.MediaType.Should().Be("text/x-shellscript");
|
||||||
|
|
||||||
|
var body = await res.Content.ReadAsStringAsync();
|
||||||
|
body.Should().StartWith("#!/usr/bin/env bash");
|
||||||
|
body.Should().Contain("BACKEND_URL=\"http://localhost\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_tarball_returns_octet_stream()
|
||||||
|
{
|
||||||
|
var tmp = Path.Combine(Path.GetTempPath(), $"client-test-{Guid.NewGuid():N}.tar.gz");
|
||||||
|
File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = factory.WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.ConfigureAppConfiguration((_, cfg) =>
|
||||||
|
{
|
||||||
|
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Install:ClientTarballPath"] = tmp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var res = await client.GetAsync("/client.tar.gz");
|
||||||
|
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
res.Content.Headers.ContentType!.MediaType.Should().Be("application/gzip");
|
||||||
|
(await res.Content.ReadAsByteArrayAsync()).Length.Should().Be(2);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tmp)) File.Delete(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using backend.Install;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class InstallScriptBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_inlines_the_backend_url_into_the_script()
|
||||||
|
{
|
||||||
|
var script = new InstallScriptBuilder().Build("https://example.test");
|
||||||
|
script.Should().Contain("BACKEND_URL=\"https://example.test\"");
|
||||||
|
script.Should().Contain("#!/usr/bin/env bash");
|
||||||
|
script.Should().Contain("$BACKEND_URL/client.tar.gz");
|
||||||
|
script.Should().Contain("client.pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace backend.Install;
|
||||||
|
|
||||||
|
public static class InstallEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapInstallEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/install.sh", (HttpContext http, InstallScriptBuilder builder) =>
|
||||||
|
{
|
||||||
|
var scheme = http.Request.Scheme;
|
||||||
|
var host = http.Request.Host.ToString();
|
||||||
|
var script = builder.Build($"{scheme}://{host}");
|
||||||
|
return Results.Text(script, "text/x-shellscript");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/client.tar.gz", (IConfiguration cfg, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var path = cfg["Install:ClientTarballPath"]
|
||||||
|
?? Path.Combine(env.WebRootPath ?? "wwwroot", "client.tar.gz");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return Results.NotFound(new { error = "client.tar.gz not found at " + path });
|
||||||
|
return Results.File(path, "application/gzip", "client.tar.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace backend.Install;
|
||||||
|
|
||||||
|
public class InstallScriptBuilder
|
||||||
|
{
|
||||||
|
public string Build(string backendUrl)
|
||||||
|
{
|
||||||
|
return $$"""
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smart Assistant installer (idempotent). Re-run to upgrade.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKEND_URL="{{backendUrl}}"
|
||||||
|
ASSISTANT_HOME="$HOME/assistant"
|
||||||
|
RESET=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--reset) RESET=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Installing system deps (sudo password may be prompted)..."
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends python3-venv libportaudio2 alsa-utils
|
||||||
|
|
||||||
|
mkdir -p "$ASSISTANT_HOME/code" "$ASSISTANT_HOME/state"
|
||||||
|
|
||||||
|
if [ ! -d "$ASSISTANT_HOME/.venv" ]; then
|
||||||
|
python3 -m venv "$ASSISTANT_HOME/.venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading client bundle from $BACKEND_URL ..."
|
||||||
|
curl -fsSL "$BACKEND_URL/client.tar.gz" | tar -xz -C "$ASSISTANT_HOME/code"
|
||||||
|
|
||||||
|
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet -r "$ASSISTANT_HOME/code/requirements.txt"
|
||||||
|
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet --no-deps -r "$ASSISTANT_HOME/code/requirements-nodeps.txt"
|
||||||
|
|
||||||
|
if [ "$RESET" -eq 1 ]; then
|
||||||
|
rm -f "$ASSISTANT_HOME/state/config.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ASSISTANT_HOME/state/config.json" ]; then
|
||||||
|
echo
|
||||||
|
read -r -p "Pairing code: " CODE
|
||||||
|
"$ASSISTANT_HOME/.venv/bin/python" -m client.pair --backend "$BACKEND_URL" --code "$CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.config/systemd/user"
|
||||||
|
cat > "$HOME/.config/systemd/user/assistant.service" <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Smart Assistant Pi client
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment=PA_ALSA_PLUGHW=1
|
||||||
|
ExecStart=$ASSISTANT_HOME/.venv/bin/python -m client.main
|
||||||
|
WorkingDirectory=$ASSISTANT_HOME/code
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
loginctl enable-linger "$USER" 2>/dev/null || true
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now assistant
|
||||||
|
echo "Done. systemctl --user status assistant"
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using backend.Auth;
|
using backend.Auth;
|
||||||
using backend.Data;
|
using backend.Data;
|
||||||
using backend.Devices;
|
using backend.Devices;
|
||||||
|
using backend.Install;
|
||||||
using backend.Pairing;
|
using backend.Pairing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ builder.Services.AddAuthorization();
|
|||||||
|
|
||||||
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
||||||
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
||||||
|
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ app.UseAuthorization();
|
|||||||
app.MapAuthEndpoints();
|
app.MapAuthEndpoints();
|
||||||
app.MapPairingEndpoints();
|
app.MapPairingEndpoints();
|
||||||
app.MapDevicesEndpoints();
|
app.MapDevicesEndpoints();
|
||||||
|
app.MapInstallEndpoints();
|
||||||
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user