236 lines
8.3 KiB
Go
236 lines
8.3 KiB
Go
package audita
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
)
|
|
|
|
// MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result.
|
|
const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024
|
|
|
|
// MaxReportOutputBytes bounds each optional Audita report.
|
|
const MaxReportOutputBytes int64 = 16 * 1024 * 1024
|
|
|
|
// SubprocessRunnerConfig defines deterministic settings for Audita CLI execution.
|
|
type SubprocessRunnerConfig struct {
|
|
Binary string
|
|
Timeout time.Duration
|
|
Credentials []configkit.Secret
|
|
Modules []string
|
|
Overlay string
|
|
ProposalProfile string
|
|
ValidationProfile string
|
|
TranscriptDescription string
|
|
ConfigPath string
|
|
OutputSchema string
|
|
}
|
|
|
|
// SubprocessRunner invokes Audita via subprocess.
|
|
type SubprocessRunner struct {
|
|
cfg SubprocessRunnerConfig
|
|
run func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
|
|
}
|
|
|
|
// NewSubprocessRunner constructs a validated Audita subprocess runner.
|
|
func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) {
|
|
if strings.TrimSpace(cfg.Binary) == "" {
|
|
return nil, fmt.Errorf("audita binary is required")
|
|
}
|
|
if cfg.Timeout <= 0 {
|
|
return nil, fmt.Errorf("audita timeout must be > 0")
|
|
}
|
|
for i, module := range cfg.Modules {
|
|
if strings.TrimSpace(module) == "" {
|
|
return nil, fmt.Errorf("audita module at index %d is empty", i)
|
|
}
|
|
}
|
|
switch strings.TrimSpace(cfg.OutputSchema) {
|
|
case "", "bare-segments", "audita-v1":
|
|
default:
|
|
return nil, fmt.Errorf("audita output schema must be one of: bare-segments, audita-v1")
|
|
}
|
|
cfg.Binary = strings.TrimSpace(cfg.Binary)
|
|
cfg.Credentials = append([]configkit.Secret(nil), cfg.Credentials...)
|
|
cfg.Modules = cloneModules(cfg.Modules)
|
|
for i := range cfg.Modules {
|
|
cfg.Modules[i] = strings.TrimSpace(cfg.Modules[i])
|
|
}
|
|
return &SubprocessRunner{cfg: cfg, run: subprocess.Run}, nil
|
|
}
|
|
|
|
func cloneModules(modules []string) []string {
|
|
if modules == nil {
|
|
return nil
|
|
}
|
|
return append([]string{}, modules...)
|
|
}
|
|
|
|
// Run executes Audita process and validates its corrected transcript.
|
|
func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) {
|
|
if r == nil {
|
|
return PolishResult{}, fmt.Errorf("audita subprocess runner is nil")
|
|
}
|
|
for _, field := range []struct{ name, value string }{
|
|
{"merged transcript path", req.MergedTranscriptPath},
|
|
{"glossary path", req.GlossaryPath},
|
|
{"output processed path", req.OutputProcessedPath},
|
|
} {
|
|
if strings.TrimSpace(field.value) == "" {
|
|
return PolishResult{}, fmt.Errorf("audita %s is required", field.name)
|
|
}
|
|
}
|
|
modules := req.Modules
|
|
if modules == nil {
|
|
modules = r.cfg.Modules
|
|
}
|
|
for i, module := range modules {
|
|
if strings.TrimSpace(module) == "" {
|
|
return PolishResult{}, fmt.Errorf("audita module at index %d is empty", i)
|
|
}
|
|
}
|
|
modules = cloneModules(modules)
|
|
for i := range modules {
|
|
modules[i] = strings.TrimSpace(modules[i])
|
|
}
|
|
args := r.buildArgs(req, modules)
|
|
env, names, err := subprocess.CredentialEnvironment(r.cfg.Credentials)
|
|
if err != nil {
|
|
return PolishResult{}, fmt.Errorf("audita credentials: %w", err)
|
|
}
|
|
if req.GeneratedConfigPath != "" {
|
|
if err := r.writeInvocationConfig(req, args, modules, names); err != nil {
|
|
return PolishResult{}, fmt.Errorf("write audita invocation config %q: %w", req.GeneratedConfigPath, err)
|
|
}
|
|
}
|
|
run := r.run
|
|
if run == nil {
|
|
run = subprocess.Run
|
|
}
|
|
runRes, runErr := run(ctx, subprocess.RunRequest{
|
|
Executable: r.cfg.Binary, Args: args, Timeout: r.cfg.Timeout,
|
|
EnvOverrides: env, SensitiveEnvNames: names, DiagnosticOwner: "audita",
|
|
StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath,
|
|
})
|
|
result := r.result(req, modules, names, runRes)
|
|
if runErr != nil {
|
|
result.Reports, _ = discoverReports(req.OutputProcessedPath)
|
|
return result, fmt.Errorf("run audita process (binary=%q, output_dir=%q, stdout_log=%q, stderr_log=%q): %w",
|
|
r.cfg.Binary, filepath.Dir(req.OutputProcessedPath), req.StdoutLogPath, req.StderrLogPath, runErr)
|
|
}
|
|
if err := validateProcessedOutput(req.OutputProcessedPath); err != nil {
|
|
result.Reports, _ = discoverReports(req.OutputProcessedPath)
|
|
return result, fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err)
|
|
}
|
|
reports, err := discoverReports(req.OutputProcessedPath)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Reports = reports
|
|
return result, nil
|
|
}
|
|
|
|
func (r *SubprocessRunner) result(req PolishRequest, modules, names []string, runRes subprocess.RunResult) PolishResult {
|
|
return PolishResult{
|
|
ProcessedTranscriptPath: req.OutputProcessedPath,
|
|
StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath,
|
|
GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode,
|
|
Duration: runRes.Duration, InvokedBinary: r.cfg.Binary,
|
|
Metadata: map[string]any{
|
|
"adapter": "audita_subprocess", "modules": modules,
|
|
"overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile,
|
|
"validation_profile": r.cfg.ValidationProfile,
|
|
"transcript_description": r.cfg.TranscriptDescription,
|
|
"config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema,
|
|
"credential_names": names,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string {
|
|
args := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath}
|
|
if modules != nil {
|
|
args = append(args, "--modules", strings.Join(modules, ","))
|
|
}
|
|
for _, setting := range []struct{ flag, value string }{
|
|
{"--config", r.cfg.ConfigPath}, {"--overlay", r.cfg.Overlay},
|
|
{"--proposal-profile", r.cfg.ProposalProfile}, {"--validation-profile", r.cfg.ValidationProfile},
|
|
{"--transcript-description", r.cfg.TranscriptDescription}, {"--output-schema", r.cfg.OutputSchema},
|
|
} {
|
|
if setting.value != "" {
|
|
args = append(args, setting.flag, setting.value)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args, modules, names []string) error {
|
|
return writeGeneratedYAML(req.GeneratedConfigPath, map[string]any{
|
|
"schema": "audita.generated.v2", "binary": r.cfg.Binary, "args": args,
|
|
"timeout": r.cfg.Timeout.String(), "modules": modules,
|
|
"overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile,
|
|
"validation_profile": r.cfg.ValidationProfile,
|
|
"transcript_description": r.cfg.TranscriptDescription,
|
|
"config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema,
|
|
"merged_transcript_path": req.MergedTranscriptPath, "glossary_path": req.GlossaryPath,
|
|
"output_path": req.OutputProcessedPath, "credential_names": names,
|
|
})
|
|
}
|
|
|
|
func discoverReports(outputPath string) (ReportBundle, error) {
|
|
var reports ReportBundle
|
|
for _, report := range []struct {
|
|
name string
|
|
dest *string
|
|
}{
|
|
{"errors.yml", &reports.ErrorsPath},
|
|
{"recoveries.yml", &reports.RecoveriesPath},
|
|
{"report.md", &reports.OverviewPath},
|
|
} {
|
|
path := filepath.Join(filepath.Dir(outputPath), report.name)
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return reports, fmt.Errorf("inspect audita report %q: %w", path, err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return reports, fmt.Errorf("audita report %q is not a regular file", path)
|
|
}
|
|
if _, err := fileops.ReadFileBounded(path, MaxReportOutputBytes); err != nil {
|
|
return reports, fmt.Errorf("audita report %q exceeds or cannot be read within %d-byte limit: %w", path, MaxReportOutputBytes, err)
|
|
}
|
|
*report.dest = path
|
|
}
|
|
return reports, nil
|
|
}
|
|
|
|
func validateProcessedOutput(path string) error {
|
|
data, err := fileops.ReadFileBounded(path, MaxProcessedOutputBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("audita processed transcript result exceeds or cannot be read within %d-byte limit: %w", MaxProcessedOutputBytes, err)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
return fmt.Errorf("parse json: %w", err)
|
|
}
|
|
segments, ok := payload["segments"]
|
|
if !ok {
|
|
return fmt.Errorf("top-level segments is required")
|
|
}
|
|
if _, ok := segments.([]any); !ok {
|
|
return fmt.Errorf("top-level segments must be an array")
|
|
}
|
|
return nil
|
|
}
|