fix(hotfix): Claude CLI via ssh на dev-vm + не хранить фото накладных
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

Owner caught (повторно, после фикса 500): контейнер stage-api пытался
запустить "claude" в своём cwd (/app), а binary там нет —
"No such file or directory". Включаем RemoteHost SSH:

1. ClaudeCliInvoiceExtractor: передаёт в ssh/scp опции -i <SshKey>,
   -o StrictHostKeyChecking=accept-new, -o UserKnownHostsFile=<SshKnownHosts>,
   -o BatchMode=yes, -o ConnectTimeout=10. Конфиги Invoices:ClaudeCli:SshKey
   и Invoices:ClaudeCli:SshKnownHosts.
2. deploy/Dockerfile.api: + openssh-client в runtime.
3. (compose + ssh-ключ на prod-vm не в репо: deploy/secrets/invoice-ocr-key,
   authorized_keys на dev-vm с from="192.168.1.190",no-pty,no-forwarding;
   env RemoteHost=nns@192.168.1.192, Executable=/home/nns/.local/bin/claude.)

Owner-policy: фото/сканы накладных НЕ хранить на серверах. InvoiceProcessor
удаляет StoredPath сразу после распознавания — успех и неудача; remote
tmp копия на dev-vm подчищается в finally. InvoiceWorker больше не содержит
sweeper (периодическая очистка не нужна), конфиг Invoices:KeepFilesDays
игнорируется (оставлен в комментарии для backwards compat).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-05 00:51:12 +05:00
parent f8480b06ae
commit 0ff93b6daa
3 changed files with 69 additions and 41 deletions

View file

@ -21,7 +21,7 @@ RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app .

View file

@ -22,6 +22,12 @@ public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
private readonly int _timeoutSec;
private readonly string? _remoteHost;
private readonly string _remoteTmp;
// Hotfix 2026-10-05: контейнер stage-api → ssh → dev-vm, где
// установлен и залогинен claude. Ключ монтируется из compose в
// read-only volume, StrictHostKeyChecking=accept-new принимает ключ
// хоста на первом коннекте и пишет в known_hosts (writable тмп-файл).
private readonly string? _sshKey;
private readonly string _sshKnownHosts;
public string Name => "claude-cli";
@ -33,6 +39,32 @@ public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger<ClaudeCliInvoiceExt
_timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180;
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
_sshKey = cfg["Invoices:ClaudeCli:SshKey"];
_sshKnownHosts = cfg["Invoices:ClaudeCli:SshKnownHosts"] ?? "/tmp/.fm-ssh/known_hosts";
}
/// <summary>Общие опции для ssh/scp: key (если задан), accept-new host
/// key, batch mode (не спрашивать пароль), connect-timeout, кастомный
/// known_hosts (писаемый путь, т.к. /root/.ssh read-only). Применяются
/// к КАЖДОМУ ssh/scp invocation'у.</summary>
private IEnumerable<string> SshCommonOpts()
{
if (!string.IsNullOrEmpty(_sshKey))
{
yield return "-i";
yield return _sshKey;
yield return "-o"; yield return "IdentitiesOnly=yes";
}
yield return "-o"; yield return "StrictHostKeyChecking=accept-new";
yield return "-o"; yield return $"UserKnownHostsFile={_sshKnownHosts}";
yield return "-o"; yield return "BatchMode=yes";
yield return "-o"; yield return "ConnectTimeout=10";
}
private void EnsureKnownHostsDir()
{
var dir = Path.GetDirectoryName(_sshKnownHosts);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
}
public bool CanHandle(string fileName, string? contentType)
@ -82,13 +114,15 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
{
if (!string.IsNullOrWhiteSpace(_remoteHost))
{
EnsureKnownHostsDir();
var sshOpts = SshCommonOpts().ToArray();
var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}";
await RunAsync("ssh", new[] { _remoteHost, $"mkdir -p {_remoteTmp}" }, null, 30, ct);
await RunAsync("scp", new[] { "-q", filePath, $"{_remoteHost}:{remote}" }, null, 120, ct);
await RunAsync("ssh", [.. sshOpts, _remoteHost, $"mkdir -p {_remoteTmp}"], null, 30, ct);
await RunAsync("scp", [.. sshOpts, "-q", filePath, $"{_remoteHost}:{remote}"], null, 120, ct);
try
{
var (code, so, se) = await RunAsync("ssh",
new[] { _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read" },
[.. sshOpts, _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read"],
BuildPrompt(remote), _timeoutSec, ct);
if (code != 0)
throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}");
@ -96,8 +130,10 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
}
finally
{
try { await RunAsync("ssh", new[] { _remoteHost, $"rm -f {remote}" }, null, 30, CancellationToken.None); }
catch { /* временный файл — не повод валить разбор */ }
// Удаляем временную копию на RemoteHost НЕЗАВИСИМО от исхода —
// owner-policy: файлы накладных на серверах не оседают.
try { await RunAsync("ssh", [.. sshOpts, _remoteHost, $"rm -f {remote}"], null, 30, CancellationToken.None); }
catch { /* тмп-файл — не повод валить разбор */ }
}
}

View file

@ -41,6 +41,10 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
throw new InvoiceExtractionException("Файл не найден на диске");
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
// Owner-policy 2026-10-05: фото/сканы накладных на сервере НЕ хранить.
// Удаляем сразу после распознавания — распознанные строки живут
// в ParsedJson, этого достаточно. См. также catch-ветку.
DeleteStored(up);
var sw = System.Diagnostics.Stopwatch.StartNew();
var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct);
@ -74,11 +78,25 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
up.Status = "failed";
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
up.ProcessedAt = DateTime.UtcNow;
// Файл удаляем и при ошибке — хранить его смысла нет, человек
// всё равно пересылает/переснимает.
DeleteStored(up);
await db.SaveChangesAsync(CancellationToken.None);
_log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId);
}
}
private static void DeleteStored(InvoiceUpload up)
{
try
{
if (!string.IsNullOrEmpty(up.StoredPath) && File.Exists(up.StoredPath))
File.Delete(up.StoredPath);
}
catch { /* файл мог быть уже удалён или FS хрюкнула — не повод падать */ }
up.StoredPath = "";
}
/// <summary>Строит matcher из каталога орги + её alias'ов.
/// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) —
/// используем IgnoreQueryFilters + явный Where по OrganizationId.</summary>
@ -121,46 +139,24 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
/// как BackgroundService (один worker на процесс). Раз в час подчищает
/// старые файлы (<c>Invoices:KeepFilesDays</c>, default 7); разобранные
/// строки остаются навсегда.</summary>
/// как BackgroundService (один worker на процесс).
///
/// Hotfix 2026-10-05 (owner-policy): фото/сканы накладных на сервере
/// хранить ЗАПРЕЩЕНО — файл удаляется ровно в <see cref="InvoiceProcessor"/>
/// сразу после разбора (успех+неудача). Поэтому периодического sweep'а
/// больше нет (<c>Invoices:KeepFilesDays</c> игнорируется — конфиг оставлен
/// как no-op для обратной совместимости и будет удалён в следующей
/// очистке конфигов). Разобранные строки (<c>ParsedJson</c>) живут
/// бессрочно.</summary>
public sealed class InvoiceWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
private readonly InvoiceProcessor _proc;
private readonly ILogger<InvoiceWorker> _log;
private DateTime _lastSweep = DateTime.MinValue;
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
{ _scopes = s; _proc = p; _log = l; }
private async Task SweepAsync(CancellationToken ct)
{
if (DateTime.UtcNow - _lastSweep < TimeSpan.FromHours(1)) return;
_lastSweep = DateTime.UtcNow;
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var days = int.TryParse(cfg["Invoices:KeepFilesDays"], out var d) ? d : 7;
var edge = DateTime.UtcNow.AddDays(-days);
var old = await db.InvoiceUploads.IgnoreQueryFilters()
.Where(x => x.StoredPath != "" && x.UploadedAt < edge)
.ToListAsync(ct);
int n = 0;
foreach (var up in old)
{
try { if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath); } catch { }
up.StoredPath = ""; n++;
}
if (n > 0)
{
await db.SaveChangesAsync(ct);
_log.LogInformation("[Invoices] swept {N} old files (>{D}d)", n, days);
}
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
@ -180,10 +176,6 @@ protected override async Task ExecuteAsync(CancellationToken ct)
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); }
try { await SweepAsync(ct); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log.LogWarning(ex, "[Invoices] sweep failed"); }
try { await Task.Delay(TimeSpan.FromSeconds(3), ct); }
catch (OperationCanceledException) { break; }
}