probe: add native OpenWrt deployment and signed automatic updates [CI]
This commit is contained in:
1 parent
24ba05fbb6
commit
cd193378a6
26 files changed
+987
-9
No files matched your search
@@ -5020,6 +5020,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/install/openwrt.sh": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"moshiro-backend"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/install": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6879,6 +6891,10 @@
|
||||
},
|
||||
"windows": {
|
||||
"type": "string"
|
||||
},
|
||||
"openwrt": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10130,6 +10146,9 @@
|
||||
{
|
||||
"name": "ops"
|
||||
},
|
||||
{
|
||||
"name": "moshiro-backend"
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
},
|
||||
|
||||
@@ -36,10 +36,16 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
|
||||
|
||||
## 配置与运行
|
||||
|
||||
### OpenWrt 原生一键部署
|
||||
|
||||
管理端「添加 Probe」提供 **OpenWrt · 原生二进制** 命令,支持 ARM64/x86_64,以 root 执行即可注册并配置 procd。支持 dev/release 自动验签更新、失败回滚,不需要容器;详见 [部署文档](../../docs/ops/deployment.md#原生一键安装与自动更新)。
|
||||
|
||||
下面是手动注册方式;现代面板需同时传入面板提供的 `--genesis` 和 `--controllers`,否则默认属主检查会拒绝启动。
|
||||
|
||||
在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成):
|
||||
|
||||
```sh
|
||||
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro
|
||||
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro --genesis <信任根指纹> --controllers <属主凭证ID>
|
||||
```
|
||||
|
||||
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe;
|
||||
|
||||
/// <summary>
|
||||
/// Native supervisor bridge: writes only fixed wake-up flags and a health beacon in tmpfs.
|
||||
/// Never downloads, installs, verifies releases, or executes commands. The root-owned host
|
||||
/// updater independently fetches and verifies its locally configured release feed.
|
||||
/// </summary>
|
||||
public sealed class OpenWrtUpdater
|
||||
{
|
||||
private readonly ConfigStore _runtime;
|
||||
private readonly string _statusPath;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
|
||||
public OpenWrtUpdater(string runtimeDirectory, string statusPath, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_runtime = new ConfigStore(runtimeDirectory);
|
||||
_statusPath = statusPath;
|
||||
_time = time;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public static OpenWrtUpdater? FromEnvironment(TimeProvider time, INodeLog log) =>
|
||||
Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable) == "openwrt"
|
||||
? new OpenWrtUpdater("/var/run/moshiro-probe", "/var/run/moshiro-update/status.json", time, log)
|
||||
: null;
|
||||
|
||||
public void Offer() => Signal("check.request");
|
||||
|
||||
public void RequestApply() => Signal("apply.request");
|
||||
|
||||
private void Signal(string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_runtime.Write(fileName, "check"u8.ToArray());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not signal the native updater", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called by the Fleet heartbeat: evidence that this version reached Backend.</summary>
|
||||
public UpdateStatus? Status()
|
||||
{
|
||||
try
|
||||
{
|
||||
var health = new OpenWrtHealth(BuildInfo.Version, Environment.ProcessId, _time.GetUtcNow().ToUnixTimeSeconds());
|
||||
_runtime.Write("health.json", JsonSerializer.SerializeToUtf8Bytes(health, ProbeConfigContext.Default.OpenWrtHealth));
|
||||
if (!File.Exists(_statusPath) || new FileInfo(_statusPath).Length > 8192)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusPath), MoShiroJsonContext.Default.UpdateStatus);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
_log.Warn("could not read native update status", ("err", ex.Message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OpenWrtHealth(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("pid")] int Pid,
|
||||
[property: JsonPropertyName("at")] long At);
|
||||
@@ -41,6 +41,7 @@ public sealed class ProbeApp : IDisposable
|
||||
directory.Changed += () => _changed.Release();
|
||||
|
||||
RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log);
|
||||
OpenWrtUpdater? nativeUpdate = OpenWrtUpdater.FromEnvironment(time, log);
|
||||
var options = new FleetLinkOptions
|
||||
{
|
||||
Server = new Uri(file.Fleet.Server),
|
||||
@@ -52,7 +53,7 @@ public sealed class ProbeApp : IDisposable
|
||||
ShutdownReason = () => restart?.NowReason == "update" ? "updating" : "stopping",
|
||||
HeartbeatExtra = () =>
|
||||
{
|
||||
UpdateStatus? updateStatus = update?.Status();
|
||||
UpdateStatus? updateStatus = nativeUpdate?.Status() ?? update?.Status();
|
||||
NodeRuntimeStatus runtimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : _env.Directory.Agents.Count == 0 ? NodeRuntimeStatus.ProbePaused : NodeRuntimeStatus.Running;
|
||||
NodeLogBatch logBatch = _logBatchSource?.TakeBatch(16) ?? new NodeLogBatch([], 0);
|
||||
return JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra
|
||||
@@ -84,11 +85,13 @@ public sealed class ProbeApp : IDisposable
|
||||
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
update?.Offer(offer);
|
||||
nativeUpdate?.Offer();
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
update?.RequestApply();
|
||||
nativeUpdate?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
|
||||
|
||||
@@ -84,6 +84,7 @@ public sealed class ProbeLimits
|
||||
|
||||
[JsonSerializable(typeof(ProbeFile))]
|
||||
[JsonSerializable(typeof(AgentsCache))]
|
||||
[JsonSerializable(typeof(OpenWrtHealth))]
|
||||
public sealed partial class ProbeConfigContext : JsonSerializerContext;
|
||||
|
||||
/// <summary><c>agents.json</c>:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。</summary>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe.Tests;
|
||||
|
||||
public sealed class OpenWrtUpdaterTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-native-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void Offer_AndApply_WriteFixedFlags_NotRemoteManifestOrCommands()
|
||||
{
|
||||
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
|
||||
updater.Offer();
|
||||
updater.RequestApply();
|
||||
Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "check.request")));
|
||||
Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "apply.request")));
|
||||
Assert.Equal(2, Directory.GetFiles(_directory).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_WritesHealthAndReadsHostStatus()
|
||||
{
|
||||
var store = new ConfigStore(_directory);
|
||||
store.Write("status.json", "{\"state\":\"rolled_back\",\"at\":123}"u8.ToArray());
|
||||
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
|
||||
Assert.Equal("rolled_back", updater.Status()?.State);
|
||||
using JsonDocument health = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(_directory, "health.json")));
|
||||
Assert.Equal(BuildInfo.Version, health.RootElement.GetProperty("version").GetString());
|
||||
Assert.Equal(Environment.ProcessId, health.RootElement.GetProperty("pid").GetInt32());
|
||||
Assert.True(health.RootElement.GetProperty("at").GetInt64() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_MissingOrCorruptHostStatus_DoesNotStopCollector()
|
||||
{
|
||||
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
|
||||
Assert.Null(updater.Status());
|
||||
new ConfigStore(_directory).Write("status.json", "not json"u8.ToArray());
|
||||
Assert.Null(updater.Status());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
{
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ public static class AgentEndpoints
|
||||
return Problems.From(http, ownerError);
|
||||
}
|
||||
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.QuadletSha256(request.Role ?? JoinTokenService.AgentRole));
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.QuadletSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.Sha256("openwrt.sh"), options.ImageNamespace == "moshiro-dev" ? "dev" : "release");
|
||||
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
|
||||
@@ -165,7 +165,7 @@ public static class AgentEndpoints
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows, created.InstallCommands.Openwrt), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
|
||||
|
||||
@@ -88,7 +88,7 @@ public sealed record SetupStatusDto(bool NeedsSetup);
|
||||
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
|
||||
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
|
||||
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows, string Openwrt = "");
|
||||
|
||||
/// <param name="Env">要存成 <c>.env</c> 的内容(只有公开的值)。</param>
|
||||
/// <param name="Url">compose 文件的下载地址。</param>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\openwrt\install.sh" LogicalName="openwrt.sh" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
|
||||
|
||||
@@ -21,6 +21,8 @@ public static class OpsEndpoints
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||||
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||||
app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript"));
|
||||
|
||||
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace MoShiro.Backend.Application;
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
|
||||
|
||||
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows, string Openwrt = "");
|
||||
|
||||
/// <summary>
|
||||
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||
@@ -16,7 +16,7 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows)
|
||||
/// </summary>
|
||||
/// <param name="ComposeSha256">面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。</param>
|
||||
/// <param name="QuadletSha256">面板提供的这个角色的 Podman Quadlet 单元的 SHA-256。</param>
|
||||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "");
|
||||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "", string OpenwrtSha256 = "", string NativeChannel = "release");
|
||||
|
||||
/// <summary>
|
||||
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 compose 文件的下载地址和哈希,
|
||||
@@ -109,7 +109,10 @@ public sealed class JoinTokenService
|
||||
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
|
||||
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
|
||||
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
|
||||
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
|
||||
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)",
|
||||
role == ProbeRole && plan.Genesis is not null
|
||||
? $"f=$(mktemp) && (curl -fsS '{baseUrl}/install/openwrt.sh' -o \"$f\" || uclient-fetch -q -O \"$f\" '{baseUrl}/install/openwrt.sh') && echo \"{plan.OpenwrtSha256} $f\" | sha256sum -c - >/dev/null && {env} MOSHIRO_CHANNEL='{plan.NativeChannel}' sh \"$f\" install"
|
||||
: "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -22,6 +22,21 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWrtScript_IsStaticAndContainsThePinnedReleasePublicKey()
|
||||
{
|
||||
string script = await _backend.Anonymous.GetStringAsync("/install/openwrt.sh");
|
||||
string key = await _backend.Anonymous.GetStringAsync("/install");
|
||||
Assert.Contains("BEGIN PUBLIC KEY", script, StringComparison.Ordinal);
|
||||
Assert.Contains(MoShiro.Backend.Api.InstallScripts.ReleaseKey(), script, StringComparison.Ordinal);
|
||||
Assert.Contains("--genesis", script, StringComparison.Ordinal);
|
||||
Assert.Contains("--controllers", script, StringComparison.Ordinal);
|
||||
Assert.Contains("openssl dgst -sha256 -verify", script, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("BEGIN PRIVATE KEY", script + key, StringComparison.Ordinal);
|
||||
JsonNode agent = await CreateAsync("agent");
|
||||
Assert.Equal("", agent["install_commands"]!["openwrt"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData("probe")]
|
||||
|
||||
@@ -70,6 +70,15 @@ public sealed class TrustTests : IClassFixture<BackendFixture>
|
||||
Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1"))));
|
||||
Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
string openwrt = commands["openwrt"]!.GetValue<string>();
|
||||
string nativeScript = await _backend.Anonymous.GetStringAsync("/install/openwrt.sh");
|
||||
string nativeSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(nativeScript)));
|
||||
Assert.Contains($"echo \"{nativeSha} $f\" | sha256sum -c -", openwrt, StringComparison.Ordinal);
|
||||
Assert.Contains($"MOSHIRO_CODE='{code}'", openwrt, StringComparison.Ordinal);
|
||||
Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", openwrt, StringComparison.Ordinal);
|
||||
Assert.Contains(op.Passkey.Id, openwrt, StringComparison.Ordinal);
|
||||
Assert.Contains("MOSHIRO_CHANNEL='release'", openwrt, StringComparison.Ordinal);
|
||||
Assert.EndsWith("sh \"$f\" install", openwrt, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in new issue
Block a user