Skip to content

fix(oscal-import): upsert existing documents and fix nested-dir recursion#449

Merged
gusfcarvalho merged 4 commits into
mainfrom
fix/oscal-import-upsert
Jul 9, 2026
Merged

fix(oscal-import): upsert existing documents and fix nested-dir recursion#449
gusfcarvalho merged 4 commits into
mainfrom
fix/oscal-import-upsert

Conversation

@gusfcarvalho

@gusfcarvalho gusfcarvalho commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Two fixes to the oscal import CLI:

  1. Re-imports now update existing documents. Import used FirstOrCreate, which silently skips any document whose uuid already exists — while logging "Successfully Created". Content changes (new catalog controls, new implemented-requirements, edited statements/by-components) never reached the database on re-import. Import now upserts: new uuids are created as before; existing documents are updated from the file with FullSaveAssociations, so nested rows are inserted or updated by primary key.

  2. Directory recursion no longer panics on subdirectories. Children were joined against the directory's base name (info.Name()) instead of the path it was opened with (f.Name()), so importing a directory that contains a subdirectory panicked on the first nested file unless the CWD happened to contain that subdirectory.

Logging now distinguishes Created vs Updated, and failures are logged as errors instead of unconditionally claiming success.

Semantics

Updates are additive:

  • Struct-based Updates skips zero-value fields, so DB-managed columns that OSCAL doesn't carry (e.g. catalogs.active, which is NOT NULL with a nil-pointer model field by design) are left untouched. A Save-based approach was tried first and violates that column's constraint — see the comment on relational.Catalog.Active.
  • Rows removed from a file are not deleted, and values cleared in a file do not clear the DB. Full replace semantics would require cascade decisions (SSP deletion cascades to risks/filters), which is out of scope here.

How this was found

Building a demo environment in local-dev: catalogs extended with new controls and SSPs extended with new implemented-requirements were silently ignored on re-import, forcing API-side workaround scripts (ensure-showcase-catalogs.sh, sync-showcase-statements.sh in local-dev). Those become no-ops once this ships. The subdirectory panic is why local-dev's showcase content lives in a top-level oscal-content-showcase/ directory instead of oscal-content/showcase/.

Testing

Verified against a live local-dev database with a locally built binary:

  • SSP v1 import → Created; v2 with edited remarks, a flipped implementation-status, and a brand-new requirement → Updated, all three changes present in the DB (requirements, statements, by-components).
  • Catalog with an added control, imported through a nested directory → recursion works, Updated, control count 1→2, active preserved as true.
  • Re-running the same import is idempotent.
  • go build, go vet, go test ./cmd/... green.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved importing of OSCAL data for nested folders and recursive inputs.
    • Imports now upsert existing records more consistently, reducing duplicate entries and mismatched updates.
    • Enhanced error handling now aggregates failures across files and reports them after processing, with panic recovery to prevent premature termination.
    • Updated “created vs updated” import logging to reflect the actual upsert outcome.

…sion

'oscal import' used FirstOrCreate, which silently skips any document whose
uuid already exists (while logging 'Successfully Created'). Content changes
- new catalog controls, new implemented-requirements, edited statements -
never reached the database on re-import, forcing API-side workarounds.

Import now upserts: new uuids are created as before; existing documents are
updated from the file with FullSaveAssociations, so nested rows (groups,
controls, requirements, statements, by-components) are inserted or updated
by primary key. Updates are additive - zero-value fields are skipped, so
DB-managed columns absent from OSCAL (e.g. catalog Active) are preserved,
and rows removed from a file are not deleted.

Also fixes directory recursion: children were joined against the directory's
base name instead of its opened path, so importing a directory containing a
subdirectory panicked on the first nested file.

Logs now say Created vs Updated, and log failures instead of unconditionally
claiming success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 9, 2026 11:55
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8568fa3e-0fd5-427d-bebc-c8283bfb2de8

📥 Commits

Reviewing files that changed from the base of the PR and between c2331e7 and 07e79ca.

📒 Files selected for processing (1)
  • cmd/oscal/import.go

📝 Walkthrough

Walkthrough

Refactors OSCAL import processing to use shared upsert and logging helpers, aggregate errors instead of stopping early, recover from panics during document import, and continue recursive directory traversal while reporting failures.

Changes

OSCAL Import Refactor

Layer / File(s) Summary
Upsert and logging helpers
cmd/oscal/import.go
Adds the errors import, importResult, reuseSingletonAssociations, upsertDocument, and logImport to create or fully update OSCAL records while preserving singleton association IDs.
Recursive import error handling
cmd/oscal/import.go
Updates nested directory opening to use the directory path, joins entry errors while continuing recursion, and converts panics in importDocuments into returned errors.
Document branch persistence
cmd/oscal/import.go
Routes OSCAL document branches through logImport(upsertDocument(...)), accumulates errors across supported document types, and returns the aggregated errors when a document is imported.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant importOscal
  participant importFile
  participant importDocuments
  participant upsertDocument
  participant GORM

  importOscal->>importFile: open and process paths
  importFile->>importDocuments: recurse into nested entries
  importDocuments->>upsertDocument: document UUID + OSCAL payload
  upsertDocument->>GORM: lookup by UUID
  alt record not found
    upsertDocument->>GORM: create entity
  else record found
    upsertDocument->>GORM: FullSaveAssociations update
  end
  upsertDocument-->>importDocuments: importResult or error
  importDocuments->>importDocuments: recover panic into error
  importOscal->>importOscal: join all import/open errors
Loading

Poem

I’m a rabbit with a tidy nose,
Through OSCAL burrows, upsert flows.
Log the hops, then join the trails,
No early stop when one path fails.
🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main user-facing fixes: document upserts and nested-directory recursion.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the oscal import CLI to correctly handle re-importing OSCAL documents by updating existing DB rows (rather than silently skipping on UUID collisions) and fixes directory recursion so nested directories resolve using the opened directory path.

Changes:

  • Replace FirstOrCreate with an upsert-style flow that creates new UUIDs and updates existing documents.
  • Fix recursive directory import to join children against f.Name() (opened path) rather than info.Name() (base name).
  • Improve import logging to distinguish Created vs Updated and to log failures as errors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cmd/oscal/import.go
Comment thread cmd/oscal/import.go Outdated
Comment thread cmd/oscal/import.go
Comment thread cmd/oscal/import.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/oscal/import.go`:
- Around line 112-122: `importFile` is swallowing upsert failures because
`logImport` only logs `res.err` while the caller still marks the import as
successful. Update the `logImport`/`importFile` flow so a failed upsert returns
the error instead of setting `imported = true`, and make the `Profile` branch
skip the sync path when the upsert result contains an error. Use the `logImport`
helper and the `Profile` branch in `importFile` to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 07fe02c5-8ff2-457a-8804-5f326a016ec4

📥 Commits

Reviewing files that changed from the base of the PR and between 3b6ef9d and c2331e7.

📒 Files selected for processing (1)
  • cmd/oscal/import.go

Comment thread cmd/oscal/import.go Outdated
… exit status

Review follow-ups:

- Updates+FullSaveAssociations does upsert nested rows (verified: edited
  by-component descriptions/statuses and new requirements land on re-import),
  but association rows without a stable OSCAL identifier - metadata and
  back-matter, whose ids are DB-generated - were duplicated on every
  re-import. reuseSingletonAssociations now points them at the existing rows
  so updates modify in place.

- Failures now reach the exit status. logImport returns the error, importFile
  joins errors across documents and directory entries (continuing past bad
  files instead of aborting the batch), and importOscal exits non-zero when
  any document failed. A panic while unmarshalling a malformed file (the
  relational layer uses uuid.MustParse) is recovered per file and converted
  into an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 9, 2026 12:07
@gusfcarvalho

Copy link
Copy Markdown
Contributor Author

Addressed both review comments in f820574:

`Updates` + `FullSaveAssociations` and nested rows — tested against a live database rather than argued from docs: nested rows are upserted through `Updates` in `FullSaveAssociations` mode (this is GORM's documented pattern for updating association data). Edited by-component descriptions/statuses and newly added requirements all land on re-import. However, the test surfaced an adjacent real bug: association rows with no stable OSCAL identifier (metadata, back-matter — their ids are DB-generated) were duplicated on every re-import, since each unmarshal mints a fresh row. `reuseSingletonAssociations` now points them at the existing rows so updates modify in place. Verified: repeated re-imports keep exactly one metadata row, updated.

Exit status on failure — agreed, fixed. `logImport` returns the error; `importFile` joins errors across documents and directory entries (continuing past bad files instead of aborting the batch — important for bulk `-f

` imports); `importOscal` exits non-zero when anything failed. Also: a malformed file used to panic the whole run (the relational layer uses `uuid.MustParse`) — that's now recovered per file and converted into an error. Verified: a directory with one bad + one good file imports the good one and exits 1; an all-good run exits 0.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.

Comment thread cmd/oscal/import.go
Comment thread cmd/oscal/import.go
Comment thread cmd/oscal/import.go
Comment thread cmd/oscal/import.go
Comment thread cmd/oscal/import.go
@gusfcarvalho gusfcarvalho enabled auto-merge (squash) July 9, 2026 12:36
Copilot AI review requested due to automatic review settings July 9, 2026 13:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.

Comment thread cmd/oscal/import.go
Comment on lines +79 to +81
if errs != nil {
sugar.Fatalw("Import finished with errors", "error", errs)
}
Comment thread cmd/oscal/import.go
Comment on lines 68 to +72
systemFile, err := os.Open(f)
if err != nil {
panic(err)
errs = errors.Join(errs, err)
sugar.Errorw("Failed to open import path", "path", f, "error", err)
continue
Comment thread cmd/oscal/import.go
Comment on lines 213 to 217
@@ -104,16 +217,31 @@ func importFile(db *gorm.DB, sugar *zap.SugaredLogger, f *os.File) error {
}
Comment thread cmd/oscal/import.go
Comment on lines +256 to 258
if err := json.NewDecoder(f).Decode(input); err != nil {
sugar.Error(err)
}
@gusfcarvalho gusfcarvalho merged commit 0b6572b into main Jul 9, 2026
6 checks passed
@gusfcarvalho gusfcarvalho deleted the fix/oscal-import-upsert branch July 9, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants