From 953f79d7b8cb932fe2171953ebd3b7b43f548935 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 8 Jul 2026 14:26:25 -0700 Subject: [PATCH 01/66] TDD: add tests for terra --- cmd/addref/terra_addref_acceptance_test.go | 9 + docs/terra-anvil-drs-download-gap-adr.md | 339 ++++++++++++++++++ docs/terra-anvil-tdd-tests.md | 154 ++++++++ .../config/terra_remote_acceptance_test.go | 36 ++ internal/lfs/terra_pointer_acceptance_test.go | 36 ++ 5 files changed, 574 insertions(+) create mode 100644 cmd/addref/terra_addref_acceptance_test.go create mode 100644 docs/terra-anvil-drs-download-gap-adr.md create mode 100644 docs/terra-anvil-tdd-tests.md create mode 100644 internal/config/terra_remote_acceptance_test.go create mode 100644 internal/lfs/terra_pointer_acceptance_test.go diff --git a/cmd/addref/terra_addref_acceptance_test.go b/cmd/addref/terra_addref_acceptance_test.go new file mode 100644 index 00000000..21c366f9 --- /dev/null +++ b/cmd/addref/terra_addref_acceptance_test.go @@ -0,0 +1,9 @@ +package addref + +import "testing" + +func TestAcceptanceAddRefExposesRemoteTypeForTerraReferences(t *testing.T) { + if Cmd.Flags().Lookup("remote-type") == nil { + t.Fatalf("expected add-ref to expose --remote-type so Terra DRS references can select a Terra resolver explicitly") + } +} diff --git a/docs/terra-anvil-drs-download-gap-adr.md b/docs/terra-anvil-drs-download-gap-adr.md new file mode 100644 index 00000000..aacde75d --- /dev/null +++ b/docs/terra-anvil-drs-download-gap-adr.md @@ -0,0 +1,339 @@ +# ADR 0004 / GitHub Issue: Support Terra/AnVIL DRS downloads in `git-drs` + +## Status + +Proposed + +## Issue type + +Architecture Decision Record / feature gap analysis + +## Download + +[Download this ADR as Markdown](./terra-anvil-drs-download-gap-adr.md) + +## Related documents + +- [Terra/AnVIL TDD acceptance tests](./terra-anvil-tdd-tests.md) + +## Summary + +`git-drs` already has most of the repository and hydration machinery needed for Terra/AnVIL, so the remaining work should be scoped as an incremental provider/identity extension rather than a wholesale redesign. The most direct path is to add a `terra` remote mode, wire Terra authentication and provider resolution at remote setup time, and then close the two compatibility gaps that are currently not well-defined for Terra references: SHA256-shaped local identity and direct DRS URL pointer identity. A manifest bootstrap workflow remains useful, but it can build on the same lower-level `add-ref`/resolver work instead of being treated as a separate large subsystem. + +## Background + +Gregor project users want a Git repository that stores project structure, manifests, analysis code, and `git-drs` pointer files while leaving controlled-access payload bytes on AnVIL. Users should be able to clone normal Git history, review which data objects are referenced, authenticate through AnVIL/Terra access controls, and hydrate only selected paths with commands such as: + +```bash +git drs pull -I "data/cohort-a/**" +``` + +Terra/AnVIL DRS downloads typically start from stable DRS URIs and use Terra-compatible authentication and resolution tooling to obtain authorized access URLs. A repository bootstrap workflow also needs to query AnVIL DRS metadata and generate pointer files and manifests deterministically. + +## Current state + +`git-drs` already provides several useful building blocks: + +- Git-compatible pointer workflows. +- `git drs track` for tracked data path patterns. +- `git drs pull -I ` for selective hydration. +- `git drs add-ref ` as an initial single-object reference workflow. +- Gen3/Syfon remote configuration. +- Local metadata and cache plumbing for DRS-backed objects. + +However, these pieces do not yet compose into a complete Terra/AnVIL workflow. + +## Decision + +Add first-class Terra/AnVIL DRS support as an incremental extension of the existing remote and reference workflows. The primary decision is to introduce `git drs remote add terra ...` so authentication and provider behavior are known at remote configuration time. Once a pointer or reference is associated with a Terra remote, `git-drs` can use a Terra-aware resolver to translate the DRS URI into downloadable access URLs. The main unresolved design decision is how to represent DRS URL identity in pointer files while preserving the existing SHA256-oriented cache and compatibility assumptions. + +## Goals + +- Allow repositories to store stable Terra/AnVIL DRS references in Git without committing payload bytes. +- Support selective hydration using existing include-filter semantics. +- Support Terra/AnVIL authentication mechanisms such as Google Application Default Credentials or bearer tokens. +- Resolve DRS URIs to authorized access URLs using GA4GH DRS and/or Terra/Martha-compatible resolution. +- Generate deterministic pointer files from manifests or workspace table exports. +- Preserve reviewability of data-reference changes in pull requests. +- Report metadata conflicts, missing checksums, inaccessible DRS IDs, and path collisions clearly. + +## Non-goals + +- Bypass AnVIL authentication or authorization. +- Mirror Gregor payload bytes into Git or unmanaged buckets. +- Replace Terra workspace data management. +- Edit AnVIL source metadata from a Git repository. +- Require all Terra/AnVIL DRS records to have SHA256 checksums. + +## Team feedback incorporated + +This ADR is revised to reflect team feedback that Terra support should be smaller than a broad re-architecture. The preferred shape is: + +- add a `terra` remote mode and wire auth/provider behavior there; +- extend `add-ref` or add a related reference command so callers can create Terra-backed DRS references explicitly; +- resolve Terra references through a remote-aware DRS resolver; +- focus design discussion on SHA256 compatibility and DRS URL pointer compatibility; +- keep manifest/bootstrap support as a batch layer over the reference primitive. + +The ADR also captures an open pointer-format question raised in review: whether `git-drs` should recognize a `git-drs`-specific pointer file whose `oid` is a DRS URI rather than a Git LFS-style SHA256 value, for example: + +```text +version https://calypr.github.io/spec/v1 +oid drs://cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c +size 11305017366 +``` + +That pointer shape is attractive for reviewability and direct DRS compatibility, but it needs an explicit cache-key strategy because existing `git-drs` code relies on SHA256-shaped OIDs in several places. + +## Gap analysis + +### Gap 1: Missing Terra/AnVIL remote type + +Current remote configuration is centered on Gen3/Syfon remotes with an `organization/project` scope. Team feedback suggests that a `terra` remote mode is the cleanest and smallest enabling feature: auth, provider selection, and resolver behavior can all be wired when the remote is added. + +Example target CLI: + +```bash +git drs remote add terra anvil \ + --drs-endpoint https://drs.anvilproject.org \ + --auth google-adc \ + --mode read-only +``` + +### Gap 2: Terra references need a remote-aware `add-ref` path + +Similar reference-building behavior already exists in `add-url`, and `add-ref` already has the right conceptual role for existing DRS objects. The missing piece is a Terra-aware reference flow where the command knows which remote type owns the reference and can call the matching resolver. + +Possible target command shapes: + +```bash +git drs add-ref --remote anvil drs://example/object data/object.cram +git drs add-ref --remote-type terra drs://example/object data/object.cram +``` + +Required behavior: + +1. Resolve the DRS URI using the selected remote. +2. Create a pointer and local metadata without downloading payload bytes. +3. Preserve the DRS URI as canonical remote identity. +4. Store or derive whatever SHA256-compatible local identity `git-drs` needs for cache compatibility. + +### Gap 3: DRS URI pointer compatibility is not well defined + +The existing design still has code paths where local pointer identity is treated as `oid sha256:<64hex>`. Terra/AnVIL can work with that constraint if the DRS URI is stored as first-class metadata and a deterministic SHA256-shaped local cache key is derived from it. However, review feedback also proposes a `git-drs`-specific pointer format where the pointer `oid` itself is a DRS URI. + +Option A: keep Git LFS-shaped pointers and store DRS URI in sidecar/local metadata. + +```text +version https://git-lfs.github.com/spec/v1 +oid sha256: +size +``` + +Option B: add a `git-drs` pointer spec that accepts DRS URI OIDs directly. + +```text +version https://calypr.github.io/spec/v1 +oid drs://example/object +size +``` + +Option A is likely lower-risk because it preserves current SHA256 assumptions. Option B is more reviewable and direct, but requires parser, cache fanout, inventory, and hydration updates so DRS URI OIDs can be normalized into safe local cache keys. + +### Gap 4: SHA256 compatibility is still non-negotiable internally + +Review feedback notes that SHA256 compatibility remains non-negotiable in current `git-drs`. Terra/AnVIL records may still lack SHA256 checksums or provide different checksum types, so the design should preserve a SHA256-shaped local/cache identity even when that identity is derived from the DRS URI rather than from payload content. + +Required behavior: + +- DRS URI is required. +- Size should be captured when available. +- SHA256 should be captured when available. +- Missing checksum should be reported as a warning or policy-controlled condition, not always a hard failure. + +### Gap 5: Missing Terra/Martha-compatible resolver + +Terra downloads often require a resolver layer that can turn a DRS URI into an authorized signed URL. `git-drs` needs a provider abstraction with Terra/AnVIL implementation. + +Suggested interface: + +```go +type DRSResolver interface { + GetObject(ctx context.Context, drsURI string) (*DRSObject, error) + GetAccessURL(ctx context.Context, drsURI string, accessIDOrType string) (*AccessURL, error) +} +``` + +Expected implementations: + +- `syfonResolver` +- `gen3Resolver` +- `ga4ghResolver` +- `terraResolver` + +### Gap 6: Manifest bootstrap/import should be a batch wrapper over reference creation + +`git-drs` still needs a batch utility to initialize or refresh repositories from authoritative AnVIL data sources, but this should be implemented as a thin wrapper around the Terra-aware reference primitive rather than a separate resolver path. + +Target command shape: + +```bash +git drs import-drs-manifest \ + --remote anvil \ + --input manifests/gregor-source.tsv \ + --drs-uri-column drs_uri \ + --path-column repo_path \ + --output-manifest manifests/gregor-drs-manifest.tsv \ + --data-root data +``` + +Required behavior: + +- Read TSV/CSV manifests or workspace table exports. +- Resolve each DRS URI. +- Validate metadata. +- Write deterministic pointer files. +- Write or update local `git-drs` metadata. +- Write a deterministic output manifest. +- Support dry-run mode. +- Report additions, changes, deletions, collisions, inaccessible IDs, and metadata conflicts. + +### Gap 7: Missing steward/reviewer manifest output + +Bootstrap should create a stable, reviewable manifest recording at least: + +- repository path; +- DRS URI; +- DRS ID; +- object name; +- size; +- checksum type and value when available; +- access method types; +- source workspace/table/row metadata when available; +- metadata status. + +Run-specific details such as timestamps should either be omitted from the committed manifest or isolated in an optional report artifact to preserve idempotency. + +### Gap 8: Current scope model does not match Terra discovery + +Terra/AnVIL source metadata may come from workspace tables, Data Repository Service snapshots, manifests, or other project-specific exports. The canonical scope for hydration should be the DRS URI stored in metadata, not a Gen3-style `organization/project` plus bucket mapping. + +### Gap 9: Access method selection needs policy + +Terra/AnVIL DRS objects may expose multiple access methods. `git-drs` should support explicit and default access preferences. + +Example target flags: + +```bash +git drs pull --access-method auto +git drs pull --access-method https +git drs pull --access-method gs +``` + +### Gap 10: Authentication UX is not Terra-native + +Terra users should be able to configure auth using expected local mechanisms. + +Target options: + +```bash +--auth google-adc +--auth gcloud +--auth bearer-token +``` + +Error messages should distinguish missing credentials, expired credentials, 401 unauthorized, 403 forbidden, inaccessible controlled-access records, and expired signed URLs. + +### Gap 11: Need read-only remote mode + +Gregor/AnVIL repositories reference existing controlled-access data. They should not accidentally trigger payload upload or DRS registration. + +Target behavior: + +```bash +git drs remote add terra anvil ... --mode read-only +``` + +In read-only mode: + +- `git drs pull` works. +- manifest import/bootstrap works. +- upload/register push behavior is disabled or fails with a clear message. + +### Gap 12: Missing Terra/AnVIL documentation + +Add documentation covering: + +- required credentials; +- remote setup; +- manifest input format; +- bootstrap workflow; +- refresh workflow; +- selective hydration; +- missing checksum policy; +- controlled-access troubleshooting; +- common 401/403 causes. + +## Proposed implementation plan + +### Phase 1: Terra remote and resolver + +- Add `git drs remote add terra ...`. +- Wire Terra auth/provider configuration at remote setup time. +- Add a Terra-aware resolver used by reference creation and hydration. +- Add provider-specific diagnostics for authorization failures. + +### Phase 2: Reference creation and pointer identity + +- Extend `add-ref` or add a related command to create Terra-backed references. +- Persist the DRS URI as canonical remote identity. +- Preserve a SHA256-shaped internal/local cache identity, either from a real checksum or a deterministic DRS URI-derived key. +- Decide whether to support direct `oid drs://...` `git-drs` pointer files in addition to Git LFS-shaped pointers. + +### Phase 3: Manifest bootstrap/import + +- Add `git drs import-drs-manifest` or equivalent as a batch wrapper over Terra-aware reference creation. +- Generate pointer files without downloading payload bytes. +- Generate deterministic output manifests. +- Add dry-run, conflict reporting, and deletion policy options. + +### Phase 4: Documentation and acceptance tests + +- Add `docs/terra-anvil.md`. +- Add unit tests for DRS-URI-derived local OIDs. +- Add resolver tests for mocked GA4GH/Terra DRS responses. +- Add integration-style tests for manifest import and selective hydration. + +## Acceptance criteria + +- A maintainer can configure a Terra/AnVIL read-only remote without Gen3/Syfon bucket mapping. +- A bootstrap/import command can generate pointer files from a representative AnVIL manifest without downloading payload bytes. +- Generated pointer metadata includes canonical DRS URIs. +- Missing SHA256 checksums are handled according to documented policy. +- Rerunning bootstrap with unchanged AnVIL metadata produces no Git diff. +- `git drs pull -I ` hydrates selected Terra/AnVIL pointer files after authentication. +- 401/403 and inaccessible DRS records produce actionable error messages. +- Documentation explains credentials, bootstrap inputs, outputs, refresh workflow, and troubleshooting. + +## Open questions + +- Should the canonical command be `git drs remote add terra` or a generic `git drs remote add ga4gh` with Terra-specific auth/resolution options? +- Which AnVIL/Terra endpoint should be treated as canonical for Gregor DRS discovery? +- Should Martha/Terra resolution be implemented directly or invoked through Terra Notebook Utils behavior? +- What should the default policy be for records that lack checksums? +- Should deleted or withdrawn AnVIL records remove pointers automatically, create tombstones, or require explicit maintainer action? +- Should bootstrap/import live in core `git-drs` or as a project-local Gregor script first? + +## Priority + +High for Terra/AnVIL adoption. + +## Labels + +- `adr` +- `enhancement` +- `terra` +- `anvil` +- `drs` +- `bootstrap` +- `controlled-access` diff --git a/docs/terra-anvil-tdd-tests.md b/docs/terra-anvil-tdd-tests.md new file mode 100644 index 00000000..40655e61 --- /dev/null +++ b/docs/terra-anvil-tdd-tests.md @@ -0,0 +1,154 @@ +# Terra/AnVIL TDD acceptance tests + +## Purpose + +This document explains the test-driven development approach for the Terra/AnVIL DRS work and how to run the tests that currently describe the desired behavior. + +The tests are intentionally written before the production implementation. They are expected to fail until `git-drs` supports: + +- a `terra` remote type; +- Terra-aware DRS reference creation; +- DRS URI-shaped `git-drs` pointer files; +- deterministic SHA256-shaped local cache identity for DRS URI references. + +These tests turn the ADR acceptance criteria into executable checks so implementation can proceed incrementally. + +## Test files + +### `internal/config/terra_remote_acceptance_test.go` + +Covers the Terra remote configuration acceptance criterion. + +The test writes Git config entries equivalent to a future Terra remote: + +```ini +[drs] + default-remote = anvil +[drs "remote.anvil"] + type = terra + endpoint = https://drs.anvilproject.org + auth = google-adc + mode = read-only +``` + +Expected future behavior: + +- `LoadConfig` recognizes `type = terra`. +- `Config.GetRemote("anvil")` returns a non-nil DRS remote. +- The remote preserves the configured Terra DRS endpoint. + +Current behavior: + +- The test fails because config parsing only models existing Gen3/local remotes. + +### `cmd/addref/terra_addref_acceptance_test.go` + +Covers the remote-aware reference creation acceptance criterion. + +Expected future behavior: + +- `git drs add-ref` exposes a `--remote-type` flag or equivalent resolver-selection mechanism. +- Terra DRS references can select Terra resolver behavior explicitly when creating pointer metadata. + +Current behavior: + +- The test fails because `add-ref` does not expose `--remote-type`. + +### `internal/lfs/terra_pointer_acceptance_test.go` + +Covers the DRS URI pointer compatibility and cache identity acceptance criteria. + +Expected future pointer behavior: + +```text +version https://calypr.github.io/spec/v1 +oid drs://cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c +size 11305017366 +``` + +Expected future cache behavior: + +- A DRS URI pointer can still be mapped to a deterministic SHA256-shaped local cache key. +- The cache key may be derived from a normalized DRS URI rather than from payload content. +- Existing cache fanout assumptions can remain SHA256-shaped even when the pointer `oid` is a DRS URI. + +Current behavior: + +- The pointer parser rejects non-`sha256` OID types. +- `ObjectPath` rejects raw DRS URIs because it currently requires a 64-character SHA256 hex string. + +## How to run the focused tests + +From the repository root, run: + +```bash +go test -timeout 30s ./internal/lfs ./internal/config ./cmd/addref +``` + +These tests are currently expected to fail. A failure is useful because it confirms the tests are exercising missing Terra/AnVIL behavior rather than silently passing against existing Gen3/Syfon-only behavior. + +## Expected current output shape + +The exact timing may differ, but the failures should include messages like: + +```text +--- FAIL: TestAcceptanceTerraDRSURIoidPointerIsRecognized + terra_pointer_acceptance_test.go: expected git-drs DRS URI pointer to be recognized + +--- FAIL: TestAcceptanceTerraDRSURIoidPointerHasDeterministicSHA256CacheKey + terra_pointer_acceptance_test.go: expected DRS URI to be normalized to a sha256-shaped cache key path + +--- FAIL: TestAcceptanceLoadTerraRemoteConfig + terra_remote_acceptance_test.go: expected terra remote to load as a DRS remote + +--- FAIL: TestAcceptanceAddRefExposesRemoteTypeForTerraReferences + terra_addref_acceptance_test.go: expected add-ref to expose --remote-type +``` + +## How to run individual test groups + +### Pointer and cache identity tests + +```bash +go test -timeout 30s ./internal/lfs -run 'TestAcceptanceTerraDRSURIoidPointer' +``` + +Use this while implementing pointer parsing, DRS URI normalization, and cache-key derivation. + +### Terra remote config test + +```bash +go test -timeout 30s ./internal/config -run TestAcceptanceLoadTerraRemoteConfig +``` + +Use this while adding config structs, parsing, and persistence for `type = terra` remotes. + +### Terra-aware `add-ref` test + +```bash +go test -timeout 30s ./cmd/addref -run TestAcceptanceAddRefExposesRemoteTypeForTerraReferences +``` + +Use this while adding CLI surface for remote-aware Terra reference creation. + +## Recommended implementation order + +1. Add a Terra remote model to config parsing and persistence. +2. Add a Terra-aware resolver abstraction that can resolve DRS URIs through the configured remote. +3. Extend `add-ref` so it can create references using a selected remote or remote type. +4. Decide the pointer-format strategy: + - keep Git LFS-shaped `oid sha256:` pointers with DRS URI in metadata; or + - support `version https://calypr.github.io/spec/v1` with `oid drs://...` directly. +5. Add deterministic DRS URI to SHA256-shaped cache-key derivation. +6. Add manifest bootstrap/import as a batch wrapper over the reference creation primitive. + +## When the tests should pass + +The focused tests should pass when `git-drs` can: + +- load a read-only Terra remote from Git config; +- expose a Terra resolver selection path for `add-ref`; +- parse DRS URI-shaped `git-drs` pointer files; +- map DRS URI identities to deterministic SHA256-shaped cache paths. + +At that point, the failing TDD tests should be treated as regression tests for the first Terra/AnVIL support increment. diff --git a/internal/config/terra_remote_acceptance_test.go b/internal/config/terra_remote_acceptance_test.go new file mode 100644 index 00000000..79b7d410 --- /dev/null +++ b/internal/config/terra_remote_acceptance_test.go @@ -0,0 +1,36 @@ +package config + +import ( + "os/exec" + "testing" +) + +func TestAcceptanceLoadTerraRemoteConfig(t *testing.T) { + repo := setupTestRepo(t) + runGitConfig := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"config"}, args...)...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config %v failed: %v: %s", args, err, string(out)) + } + } + + runGitConfig("drs.default-remote", "anvil") + runGitConfig("drs.remote.anvil.type", "terra") + runGitConfig("drs.remote.anvil.endpoint", "https://drs.anvilproject.org") + runGitConfig("drs.remote.anvil.auth", "google-adc") + runGitConfig("drs.remote.anvil.mode", "read-only") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + remote := cfg.GetRemote(Remote("anvil")) + if remote == nil { + t.Fatalf("expected terra remote to load as a DRS remote") + } + if got := remote.GetEndpoint(); got != "https://drs.anvilproject.org" { + t.Fatalf("unexpected terra endpoint: %q", got) + } +} diff --git a/internal/lfs/terra_pointer_acceptance_test.go b/internal/lfs/terra_pointer_acceptance_test.go new file mode 100644 index 00000000..7112c866 --- /dev/null +++ b/internal/lfs/terra_pointer_acceptance_test.go @@ -0,0 +1,36 @@ +package lfs + +import "testing" + +func TestAcceptanceTerraDRSURIoidPointerIsRecognized(t *testing.T) { + const pointer = `version https://calypr.github.io/spec/v1 +oid drs://cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c +size 11305017366 +` + + info, ok := parseLFSPointer(pointer) + if !ok { + t.Fatalf("expected git-drs DRS URI pointer to be recognized") + } + if info.Version != "https://calypr.github.io/spec/v1" { + t.Fatalf("unexpected pointer version: %q", info.Version) + } + if info.OidType != "drs" || info.Oid != "//cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c" { + t.Fatalf("expected DRS URI oid to be preserved, got type=%q oid=%q", info.OidType, info.Oid) + } + if info.Size != 11305017366 { + t.Fatalf("unexpected pointer size: %d", info.Size) + } +} + +func TestAcceptanceTerraDRSURIoidPointerHasDeterministicSHA256CacheKey(t *testing.T) { + const drsURI = "drs://cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c" + + cacheKeyPath, err := ObjectPath(".git/drs/objects", drsURI) + if err != nil { + t.Fatalf("expected DRS URI to be normalized to a sha256-shaped cache key path: %v", err) + } + if cacheKeyPath == "" { + t.Fatalf("expected non-empty cache path for DRS URI") + } +} From c4580fcfe1a3b1baf190a7bc9f814c3ebc341863 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 8 Jul 2026 14:53:01 -0700 Subject: [PATCH 02/66] Support DRS URI pointers in LFS cache paths --- internal/lfs/inventory.go | 19 +++++++++---- internal/lfs/object_path.go | 49 +++++++++++++++++++++++++++++--- internal/lfs/object_path_test.go | 14 +++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/internal/lfs/inventory.go b/internal/lfs/inventory.go index 259b0f29..bf136913 100644 --- a/internal/lfs/inventory.go +++ b/internal/lfs/inventory.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "strconv" "strings" @@ -433,7 +432,7 @@ func readIndexPointerInfo(ctx context.Context, repoDir, path string) (LfsFileInf } func grepPointerPaths(ctx context.Context, repoDir, ref string) ([]string, error) { - cmd := exec.CommandContext(ctx, "git", "grep", "-z", "-l", "https://git-lfs.github.com/spec/v1", ref, "--") + cmd := exec.CommandContext(ctx, "git", "grep", "-z", "-l", "-e", "https://git-lfs.github.com/spec/v1", "-e", "https://calypr.github.io/spec/v1", ref, "--") cmd.Dir = repoDir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -493,8 +492,6 @@ type lfsPointer struct { func parseLFSPointer(content string) (lfsPointer, bool) { var p lfsPointer - sha256Re := regexp.MustCompile(`(?i)^[a-f0-9]{64}$`) - for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { line = strings.TrimSpace(line) if line == "" { @@ -527,7 +524,19 @@ func parseLFSPointer(content string) (lfsPointer, bool) { if p.Version == "" || p.OidType == "" || p.Oid == "" { return lfsPointer{}, false } - if p.OidType != "sha256" || !sha256Re.MatchString(p.Oid) { + switch strings.ToLower(p.OidType) { + case "sha256": + p.OidType = "sha256" + if !sha256OIDRe.MatchString(p.Oid) { + return lfsPointer{}, false + } + p.Oid = strings.ToLower(p.Oid) + case "drs": + p.OidType = "drs" + if p.Version != "https://calypr.github.io/spec/v1" || !strings.HasPrefix(p.Oid, "//") { + return lfsPointer{}, false + } + default: return lfsPointer{}, false } diff --git a/internal/lfs/object_path.go b/internal/lfs/object_path.go index 7085cbef..1f25ac05 100644 --- a/internal/lfs/object_path.go +++ b/internal/lfs/object_path.go @@ -1,17 +1,58 @@ package lfs import ( + "crypto/sha256" + "encoding/hex" "fmt" "path/filepath" + "regexp" "strings" ) -// ObjectPath returns the Git LFS fanout path for a sha256 object ID. +var sha256OIDRe = regexp.MustCompile(`(?i)^[a-f0-9]{64}$`) + +// ObjectPath returns the Git LFS fanout path for an object ID. +// +// The cache layout remains SHA256-shaped for compatibility with existing LFS +// object storage. Git LFS pointers pass their sha256 object IDs through +// unchanged, while git-drs DRS URI pointers are assigned a deterministic local +// cache key derived from the normalized DRS URI. func ObjectPath(basePath string, oid string) (string, error) { + cacheKey, err := cacheKeyForOID(oid) + if err != nil { + return "", err + } + + return filepath.Join(basePath, cacheKey[:2], cacheKey[2:4], cacheKey), nil +} + +func cacheKeyForOID(oid string) (string, error) { + oid = strings.TrimSpace(oid) oid = strings.TrimPrefix(oid, "sha256:") - if len(oid) != 64 { - return "", fmt.Errorf("error: %s is not a valid sha256 hash", oid) + if sha256OIDRe.MatchString(oid) { + return strings.ToLower(oid), nil + } + + if isDRSURI(oid) { + sum := sha256.Sum256([]byte(normalizeDRSURI(oid))) + return hex.EncodeToString(sum[:]), nil } - return filepath.Join(basePath, oid[:2], oid[2:4], oid), nil + return "", fmt.Errorf("error: %s is not a valid sha256 hash or DRS URI", oid) +} + +func isDRSURI(uri string) bool { + uri = strings.TrimSpace(uri) + return strings.HasPrefix(strings.ToLower(uri), "drs://") || strings.HasPrefix(uri, "//") +} + +func normalizeDRSURI(uri string) string { + uri = strings.TrimSpace(uri) + if strings.HasPrefix(uri, "//") { + return "drs:" + uri + } + if len(uri) >= len("drs://") && strings.EqualFold(uri[:len("drs://")], "drs://") { + return "drs://" + uri[len("drs://"):] + } + return uri } diff --git a/internal/lfs/object_path_test.go b/internal/lfs/object_path_test.go index 4c918172..e0ab6473 100644 --- a/internal/lfs/object_path_test.go +++ b/internal/lfs/object_path_test.go @@ -8,3 +8,17 @@ func TestObjectPathRejectsInvalidOID(t *testing.T) { t.Fatalf("expected validation error, got %s", path) } } + +func TestObjectPathAcceptsDRSPointerOIDValue(t *testing.T) { + fullURIPath, err := ObjectPath(".git/drs/objects", "drs://cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c") + if err != nil { + t.Fatalf("expected DRS URI object path: %v", err) + } + pointerOIDPath, err := ObjectPath(".git/drs/objects", "//cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c") + if err != nil { + t.Fatalf("expected DRS pointer oid object path: %v", err) + } + if pointerOIDPath != fullURIPath { + t.Fatalf("expected DRS pointer oid and full URI to produce same path, got %q and %q", pointerOIDPath, fullURIPath) + } +} From ba1a2ea24257c30d12830703b384374e7a1b7544 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 8 Jul 2026 15:01:58 -0700 Subject: [PATCH 03/66] Adds Terra remote model, type --- cmd/addref/add-ref.go | 2 ++ internal/config/config.go | 22 +++++++++++++++++++++- internal/config/remote.go | 13 +++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 84f3b7f0..614f8e37 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -14,6 +14,7 @@ import ( ) var remote string +var remoteType string var Cmd = &cobra.Command{ Use: "add-ref ", Short: "Add a reference to an existing DRS object via URI", @@ -61,4 +62,5 @@ var Cmd = &cobra.Command{ func init() { Cmd.Flags().StringVarP(&remote, "remote", "r", "", "target remote DRS server (default: default_remote)") + Cmd.Flags().StringVar(&remoteType, "remote-type", "", "resolver remote type for DRS references (for example: terra)") } diff --git a/internal/config/config.go b/internal/config/config.go index 6006626e..7c9dfe22 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ const ( Gen3ServerType RemoteType = "gen3" LocalServerType RemoteType = "local" + TerraServerType RemoteType = "terra" configSection = "drs" remoteSubsectionPrefix = "remote." @@ -42,6 +43,8 @@ func (c Config) GetRemote(remote Remote) DRSRemote { return x.Gen3 } else if x.Local != nil { return x.Local + } else if x.Terra != nil { + return x.Terra } return nil } @@ -130,6 +133,15 @@ func UpdateRemote(name Remote, remote RemoteSelect) (*Config, error) { if remote.Gen3.StoragePrefix != "" { remoteSubsection.SetOption("storage_prefix", remote.Gen3.StoragePrefix) } + } else if remote.Terra != nil { + remoteSubsection.SetOption("type", "terra") + remoteSubsection.SetOption("endpoint", remote.Terra.Endpoint) + if remote.Terra.Auth != "" { + remoteSubsection.SetOption("auth", remote.Terra.Auth) + } + if remote.Terra.Mode != "" { + remoteSubsection.SetOption("mode", remote.Terra.Mode) + } } else if remote.Local != nil { remoteSubsection.SetOption("type", "local") remoteSubsection.SetOption("endpoint", remote.Local.BaseURL) @@ -162,7 +174,7 @@ func UpdateRemote(name Remote, remote RemoteSelect) (*Config, error) { return LoadConfig() } -func parseAndAddRemote(cfg *Config, subsectionName string, remoteType string, endpoint string, project string, bucket string, organization string, storagePrefix string) { +func parseAndAddRemote(cfg *Config, subsectionName string, remoteType string, endpoint string, project string, bucket string, organization string, storagePrefix string, auth string, mode string) { if !strings.HasPrefix(subsectionName, remoteSubsectionPrefix) { return } @@ -178,6 +190,12 @@ func parseAndAddRemote(cfg *Config, subsectionName string, remoteType string, en Organization: organization, StoragePrefix: storagePrefix, } + } else if remoteType == "terra" { + rs.Terra = &TerraRemote{ + Endpoint: endpoint, + Auth: auth, + Mode: mode, + } } else if remoteType == "local" { rs.Local = &LocalRemote{ BaseURL: endpoint, @@ -232,6 +250,8 @@ func LoadConfig() (*Config, error) { subsection.Option("bucket"), subsection.Option("organization"), subsection.Option("storage_prefix"), + subsection.Option("auth"), + subsection.Option("mode"), ) } } diff --git a/internal/config/remote.go b/internal/config/remote.go index f08ae114..000e8bf9 100644 --- a/internal/config/remote.go +++ b/internal/config/remote.go @@ -11,6 +11,7 @@ type DRSRemote interface { type RemoteSelect struct { Gen3 *Gen3Remote Local *LocalRemote + Terra *TerraRemote } type Gen3Remote struct { @@ -27,6 +28,18 @@ func (s Gen3Remote) GetEndpoint() string { return s.Endpoint } func (s Gen3Remote) GetBucketName() string { return s.Bucket } func (s Gen3Remote) GetStoragePrefix() string { return s.StoragePrefix } +type TerraRemote struct { + Endpoint string `yaml:"endpoint"` + Auth string `yaml:"auth"` + Mode string `yaml:"mode"` +} + +func (t TerraRemote) GetProjectId() string { return "" } +func (t TerraRemote) GetOrganization() string { return "" } +func (t TerraRemote) GetEndpoint() string { return t.Endpoint } +func (t TerraRemote) GetBucketName() string { return "" } +func (t TerraRemote) GetStoragePrefix() string { return "" } + type LocalRemote struct { BaseURL string ProjectID string From f4ce335534d01ffc32e252f2a91d5816411c6cdf Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 8 Jul 2026 16:25:21 -0700 Subject: [PATCH 04/66] Updated git drs ping for Terra --- cmd/ping/main.go | 56 +++++++++++++++++++++-- cmd/ping/main_test.go | 62 ++++++++++++++++++++++++++ docs/terra-anvil-tdd-tests.md | 54 +++++++++++++++++++--- internal/remoteruntime/runtime.go | 24 ++++++++++ internal/remoteruntime/runtime_test.go | 29 ++++++++++++ internal/testutils/config.go | 9 ++++ 6 files changed, 226 insertions(+), 8 deletions(-) diff --git a/cmd/ping/main.go b/cmd/ping/main.go index 3069018a..62a3cb9d 100644 --- a/cmd/ping/main.go +++ b/cmd/ping/main.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/url" "strings" "github.com/calypr/git-drs/internal/config" @@ -28,6 +30,9 @@ type statusInfo struct { } var pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) error { + if gc != nil && gc.RemoteType == config.TerraServerType { + return pingTerraServiceInfo(ctx, gc.Endpoint) + } return gc.Client.Health().Ping(ctx) } @@ -127,6 +132,8 @@ func resolveStatus(args []string, logger *slog.Logger) (statusInfo, *remoterunti status.RemoteType = string(config.Gen3ServerType) case *config.LocalRemote: status.RemoteType = string(config.LocalServerType) + case *config.TerraRemote: + status.RemoteType = string(config.TerraServerType) default: status.RemoteType = "unknown" } @@ -171,11 +178,10 @@ func blankIfEmpty(v string) string { } func checkScopeAccess(ctx context.Context, gc *remoteruntime.GitContext) (scopeAccessInfo, error) { - if gc == nil || gc.Client == nil { + info := scopeAccessInfo{} + if gc == nil { return scopeAccessInfo{}, fmt.Errorf("DRS client unavailable") } - - info := scopeAccessInfo{} organization := strings.TrimSpace(gc.Organization) project := strings.TrimSpace(gc.ProjectId) bucket := strings.TrimSpace(gc.BucketName) @@ -183,6 +189,9 @@ func checkScopeAccess(ctx context.Context, gc *remoteruntime.GitContext) (scopeA if organization == "" && project == "" && bucket == "" { return info, nil } + if gc.Client == nil { + return scopeAccessInfo{}, fmt.Errorf("DRS client unavailable") + } info.Checked = true if organization != "" && project != "" { @@ -211,6 +220,47 @@ func checkScopeAccess(ctx context.Context, gc *remoteruntime.GitContext) (scopeA return info, nil } +func pingTerraServiceInfo(ctx context.Context, endpoint string) error { + if ctx == nil { + ctx = context.Background() + } + serviceInfoURL, err := terraServiceInfoURL(endpoint) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceInfoURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("terra DRS service-info returned %s", resp.Status) + } + return nil +} + +func terraServiceInfoURL(endpoint string) (string, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "", fmt.Errorf("terra endpoint is empty") + } + parsed, err := url.Parse(endpoint) + if err != nil { + return "", err + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("terra endpoint must be an absolute URL: %q", endpoint) + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/ga4gh/drs/v1/service-info" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + func visibleBucketForScope(ctx context.Context, gc *remoteruntime.GitContext, organization, project string) (string, error) { payload, err := gc.Client.Buckets().List(ctx) if err != nil { diff --git a/cmd/ping/main_test.go b/cmd/ping/main_test.go index eb1c8141..81e290e4 100644 --- a/cmd/ping/main_test.go +++ b/cmd/ping/main_test.go @@ -5,6 +5,8 @@ import ( "context" "errors" "io" + "net/http" + "net/http/httptest" "os" "strings" "testing" @@ -28,6 +30,66 @@ func TestPingCmdArgs(t *testing.T) { } } +func TestAcceptancePingTerraDRSServer(t *testing.T) { + var serviceInfoRequests int + terraDRS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ga4gh/drs/v1/service-info" { + t.Fatalf("expected Terra DRS service-info ping, got %s %s", r.Method, r.URL.Path) + } + serviceInfoRequests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"terra-drs","name":"Terra DRS","type":{"group":"org.ga4gh","artifact":"drs","version":"1.0.0"}}`)) + })) + t.Cleanup(terraDRS.Close) + + tmpDir := testutils.SetupTestGitRepo(t) + testutils.CreateTestConfig(t, tmpDir, &config.Config{ + DefaultRemote: config.Remote("anvil"), + Remotes: map[config.Remote]config.RemoteSelect{ + config.Remote("anvil"): { + Terra: &config.TerraRemote{ + Endpoint: terraDRS.URL, + Auth: "google-adc", + Mode: "read-only", + }, + }, + }, + }) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = oldStdout }) + + runErr := Cmd.RunE(Cmd, []string{"anvil"}) + _ = w.Close() + if runErr != nil { + t.Fatalf("Cmd.RunE returned error: %v", runErr) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + got := buf.String() + for _, want := range []string{ + "remote: anvil (default)", + "type: terra", + "endpoint: " + terraDRS.URL, + "health: ok", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got %q", want, got) + } + } + if serviceInfoRequests != 1 { + t.Fatalf("expected exactly one Terra DRS service-info ping, got %d", serviceInfoRequests) + } +} + func TestResolveStatusLocalRemote(t *testing.T) { tmpDir := testutils.SetupTestGitRepo(t) testutils.CreateTestConfig(t, tmpDir, &config.Config{ diff --git a/docs/terra-anvil-tdd-tests.md b/docs/terra-anvil-tdd-tests.md index 40655e61..899832d1 100644 --- a/docs/terra-anvil-tdd-tests.md +++ b/docs/terra-anvil-tdd-tests.md @@ -54,6 +54,38 @@ Current behavior: - The test fails because `add-ref` does not expose `--remote-type`. +### `cmd/ping/main_test.go` + +Covers the Terra remote connectivity acceptance criterion for `git drs ping`. + +The acceptance test configures a future Terra remote named `anvil` and uses an +`httptest` server to stand in for the Terra/AnVIL DRS endpoint: + +```ini +[drs] + default-remote = anvil +[drs "remote.anvil"] + type = terra + endpoint = http://127.0.0.1: + auth = google-adc + mode = read-only +``` + +Expected future behavior: + +- `git drs ping anvil` recognizes the configured `terra` remote. +- The command prints Terra remote status, including `type: terra` and the + configured endpoint. +- The command pings the Terra/AnVIL DRS service-info route at + `/ga4gh/drs/v1/service-info`. +- A successful service-info response is reported as `health: ok`. + +Current behavior: + +- The test passes once runtime construction recognizes Terra remotes and + `git drs ping` checks the configured DRS service-info endpoint. +- This test should remain a regression test for Terra/AnVIL ping connectivity. + ### `internal/lfs/terra_pointer_acceptance_test.go` Covers the DRS URI pointer compatibility and cache identity acceptance criteria. @@ -82,7 +114,7 @@ Current behavior: From the repository root, run: ```bash -go test -timeout 30s ./internal/lfs ./internal/config ./cmd/addref +go test -timeout 30s ./internal/lfs ./internal/config ./cmd/addref ./cmd/ping ``` These tests are currently expected to fail. A failure is useful because it confirms the tests are exercising missing Terra/AnVIL behavior rather than silently passing against existing Gen3/Syfon-only behavior. @@ -103,6 +135,7 @@ The exact timing may differ, but the failures should include messages like: --- FAIL: TestAcceptanceAddRefExposesRemoteTypeForTerraReferences terra_addref_acceptance_test.go: expected add-ref to expose --remote-type + ``` ## How to run individual test groups @@ -131,22 +164,33 @@ go test -timeout 30s ./cmd/addref -run TestAcceptanceAddRefExposesRemoteTypeForT Use this while adding CLI surface for remote-aware Terra reference creation. +### Terra-aware `ping` test + +```bash +go test -timeout 30s ./cmd/ping -run TestAcceptancePingTerraDRSServer +``` + +Use this while wiring Terra remotes into runtime construction and adding the +Terra/AnVIL DRS service-info health probe used by `git drs ping`. + ## Recommended implementation order 1. Add a Terra remote model to config parsing and persistence. 2. Add a Terra-aware resolver abstraction that can resolve DRS URIs through the configured remote. -3. Extend `add-ref` so it can create references using a selected remote or remote type. -4. Decide the pointer-format strategy: +3. Add a Terra-aware ping path that checks the configured DRS service-info endpoint. +4. Extend `add-ref` so it can create references using a selected remote or remote type. +5. Decide the pointer-format strategy: - keep Git LFS-shaped `oid sha256:` pointers with DRS URI in metadata; or - support `version https://calypr.github.io/spec/v1` with `oid drs://...` directly. -5. Add deterministic DRS URI to SHA256-shaped cache-key derivation. -6. Add manifest bootstrap/import as a batch wrapper over the reference creation primitive. +6. Add deterministic DRS URI to SHA256-shaped cache-key derivation. +7. Add manifest bootstrap/import as a batch wrapper over the reference creation primitive. ## When the tests should pass The focused tests should pass when `git-drs` can: - load a read-only Terra remote from Git config; +- ping the configured Terra/AnVIL DRS service-info endpoint; - expose a Terra resolver selection path for `add-ref`; - parse DRS URI-shaped `git-drs` pointer files; - map DRS URI identities to deterministic SHA256-shaped cache paths. diff --git a/internal/remoteruntime/runtime.go b/internal/remoteruntime/runtime.go index 39410cdc..009fbda2 100644 --- a/internal/remoteruntime/runtime.go +++ b/internal/remoteruntime/runtime.go @@ -20,6 +20,8 @@ const credentialHelpSuffix = "Refresh credentials with `git drs remote add gen3 type GitContext struct { Client *syclient.Client + RemoteType config.RemoteType + Endpoint string Organization string ProjectId string BucketName string @@ -47,9 +49,27 @@ func New(cfg *config.Config, remote config.Remote, logger *slog.Logger) (*GitCon } return gen3Client(string(remote), *x.Gen3, logger) } + if x.Terra != nil { + return terraClient(*x.Terra, logger) + } return nil, fmt.Errorf("no valid remote configuration found for current remote: %s", remote) } +func terraClient(remote config.TerraRemote, logger *slog.Logger) (*GitContext, error) { + if strings.TrimSpace(remote.Endpoint) == "" { + return nil, fmt.Errorf("no terra endpoint specified") + } + if _, err := url.Parse(remote.Endpoint); err != nil { + return nil, err + } + return &GitContext{ + RemoteType: config.TerraServerType, + Endpoint: remote.Endpoint, + Logger: logger, + Credential: &syconf.Credential{APIEndpoint: remote.Endpoint}, + }, nil +} + func gen3Client(remoteName string, remote config.Gen3Remote, logger *slog.Logger) (*GitContext, error) { manager := calyprconf.NewConfigure(logger) cred, err := manager.Load(remoteName) @@ -106,6 +126,8 @@ func localClient(remoteName string, remote config.LocalRemote, logger *slog.Logg return &GitContext{ Client: client, + RemoteType: config.LocalServerType, + Endpoint: remote.BaseURL, Organization: remote.GetOrganization(), ProjectId: projectID, BucketName: bucketName, @@ -150,6 +172,8 @@ func newGitContext(profileConfig syconf.Credential, remote config.Gen3Remote, lo return &GitContext{ Client: client, + RemoteType: config.Gen3ServerType, + Endpoint: profileConfig.APIEndpoint, ProjectId: projectID, BucketName: scope.Bucket, Organization: remote.GetOrganization(), diff --git a/internal/remoteruntime/runtime_test.go b/internal/remoteruntime/runtime_test.go index 2b4d6720..2b083439 100644 --- a/internal/remoteruntime/runtime_test.go +++ b/internal/remoteruntime/runtime_test.go @@ -59,6 +59,35 @@ func TestNewLocalIncludesRepoBasicAuth(t *testing.T) { } } +func TestNewTerraRemoteContext(t *testing.T) { + setupTestRepo(t) + + cfg, err := config.UpdateRemote(config.Remote("anvil"), config.RemoteSelect{ + Terra: &config.TerraRemote{ + Endpoint: "https://drs.anvilproject.org", + Auth: "google-adc", + Mode: "read-only", + }, + }) + if err != nil { + t.Fatalf("UpdateRemote failed: %v", err) + } + + gitCtx, err := New(cfg, config.Remote("anvil"), drslog.GetLogger()) + if err != nil { + t.Fatalf("New failed: %v", err) + } + if gitCtx.RemoteType != config.TerraServerType { + t.Fatalf("RemoteType = %q, want %q", gitCtx.RemoteType, config.TerraServerType) + } + if gitCtx.Endpoint != "https://drs.anvilproject.org" { + t.Fatalf("Endpoint = %q, want Terra endpoint", gitCtx.Endpoint) + } + if gitCtx.Client != nil { + t.Fatalf("Terra runtime should not create a Syfon client, got %+v", gitCtx.Client) + } +} + func TestLocalClientResolvesBucketScopeMappings(t *testing.T) { setupTestRepo(t) diff --git a/internal/testutils/config.go b/internal/testutils/config.go index e8ab73bc..88a66f35 100644 --- a/internal/testutils/config.go +++ b/internal/testutils/config.go @@ -70,6 +70,15 @@ func CreateTestConfig(t *testing.T, tmpDir string, cfg *config.Config) { if remote.Gen3.Organization != "" { setConfig(prefix+".organization", remote.Gen3.Organization) } + } else if remote.Terra != nil { + setConfig(prefix+".type", "terra") + setConfig(prefix+".endpoint", remote.Terra.Endpoint) + if remote.Terra.Auth != "" { + setConfig(prefix+".auth", remote.Terra.Auth) + } + if remote.Terra.Mode != "" { + setConfig(prefix+".mode", remote.Terra.Mode) + } } else if remote.Local != nil { setConfig(prefix+".type", "local") setConfig(prefix+".endpoint", remote.Local.BaseURL) From 35373553e348076d88b7fdf87a38492d0fd10eeb Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 8 Jul 2026 17:37:18 -0700 Subject: [PATCH 05/66] adds terra ping --- cmd/ping/terra_integration_test.go | 24 +++++++++ docs/commands.md | 53 +++++++++++++++++++ docs/terra-anvil-drs-download-gap-adr.md | 14 ++++- docs/terra-anvil-tdd-tests.md | 2 +- .../config/terra_remote_acceptance_test.go | 4 +- internal/remoteruntime/runtime_test.go | 4 +- 6 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 cmd/ping/terra_integration_test.go diff --git a/cmd/ping/terra_integration_test.go b/cmd/ping/terra_integration_test.go new file mode 100644 index 00000000..df48a81c --- /dev/null +++ b/cmd/ping/terra_integration_test.go @@ -0,0 +1,24 @@ +//go:build integration + +package ping + +import ( + "context" + "os" + "testing" + "time" +) + +func TestIntegrationPingTerraDRSServer(t *testing.T) { + endpoint := os.Getenv("GIT_DRS_TERRA_DRS_ENDPOINT") + if endpoint == "" { + endpoint = "https://data.terra.bio" + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + + if err := pingTerraServiceInfo(ctx, endpoint); err != nil { + t.Fatalf("ping Terra DRS service-info endpoint %q: %v", endpoint, err) + } +} diff --git a/docs/commands.md b/docs/commands.md index 6fa57edd..a588a7d4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -75,6 +75,59 @@ List configured `git-drs` remotes. git drs remote list ``` +### `git drs ping [remote-name]` + +Show the effective `git-drs` remote configuration and verify that the remote responds. + +```bash +git drs ping +git drs ping anvil +``` + +What it checks: + +- prints the selected remote, remote type, endpoint, scope, bucket, storage prefix, and auth mode +- runs a health check against the selected remote +- for Terra DRS remotes, pings the GA4GH DRS service-info endpoint at `/ga4gh/drs/v1/service-info` +- for Terra/AnVIL TDR-hosted data in production, use `https://data.terra.bio` as the endpoint; Terra also uses DRSHub for DRS URI resolution, but DRSHub is a resolver service rather than the GA4GH DRS service-info host +- for scoped Syfon-style remotes, verifies that the configured organization/project and bucket are visible and readable + +Example Terra configuration and ping: + +```bash +git config drs.default-remote anvil +git config drs.remote.anvil.type terra +git config drs.remote.anvil.endpoint https://data.terra.bio +git config drs.remote.anvil.auth google-adc +git config drs.remote.anvil.mode read-only +git drs ping anvil +``` + +A successful Terra ping includes: + +```text +remote: anvil (default) +type: terra +endpoint: https://data.terra.bio +health: ok +``` + +Troubleshooting: + +- `no remote configuration found`: run `git drs remote list` and pass an existing remote name, or configure the Terra remote with the `git config` commands above. +- `terra endpoint is empty`: set `drs.remote..endpoint` to the Terra DRS base URL, for example `https://data.terra.bio` for the production Terra Data Repository DRS service. +- `terra endpoint must be an absolute URL`: include the URL scheme, for example `https://data.terra.bio` instead of `data.terra.bio`. +- `terra DRS service-info returned ...`: verify the server is up and that the base endpoint is correct. You can test the exact URL with `curl -i https://data.terra.bio/ga4gh/drs/v1/service-info`. +- network, DNS, or TLS errors: check VPN/proxy/firewall settings and retry with `GIT_CURL_VERBOSE=1 git drs ping ` for additional HTTP diagnostics from Git-adjacent workflows. + +For developers, the live Terra ping integration test is intentionally behind the `integration` build tag because it reaches the public Terra DRS service: + +```bash +go test -tags=integration ./cmd/ping -run TestIntegrationPingTerraDRSServer -count=1 +``` + +Set `GIT_DRS_TERRA_DRS_ENDPOINT` to point the test at a different Terra DRS deployment. Terra's DRSHub resolver URL follows the `https://drshub.dsde-.broadinstitute.org/api/v4/drs/resolve` pattern used by `terra-notebook-utils`, but `git drs ping` needs the GA4GH DRS service base URL that exposes `/ga4gh/drs/v1/service-info`; for Terra production that service base URL is `https://data.terra.bio`. + ### `git drs remote remove ` Remove a configured `git-drs` remote. diff --git a/docs/terra-anvil-drs-download-gap-adr.md b/docs/terra-anvil-drs-download-gap-adr.md index aacde75d..55c84db7 100644 --- a/docs/terra-anvil-drs-download-gap-adr.md +++ b/docs/terra-anvil-drs-download-gap-adr.md @@ -16,10 +16,22 @@ Architecture Decision Record / feature gap analysis - [Terra/AnVIL TDD acceptance tests](./terra-anvil-tdd-tests.md) +## Short answer: Terra DRS service-info endpoint + +Yes. Terra Data Repository exposes the GA4GH DRS service-info endpoint at: + +```text +https://data.terra.bio/ga4gh/drs/v1/service-info +``` + +For `git-drs` Terra health checks, `https://data.terra.bio` is the Terra DRS service base URL and `git drs ping` derives `/ga4gh/drs/v1/service-info` from that base. Terra also uses DRSHub for DRS URI resolution; DRSHub URLs such as `https://drshub.dsde-.broadinstitute.org/api/v4/drs/resolve` are resolver API URLs, not the GA4GH DRS service-info endpoint that `git drs ping` should use. + ## Summary `git-drs` already has most of the repository and hydration machinery needed for Terra/AnVIL, so the remaining work should be scoped as an incremental provider/identity extension rather than a wholesale redesign. The most direct path is to add a `terra` remote mode, wire Terra authentication and provider resolution at remote setup time, and then close the two compatibility gaps that are currently not well-defined for Terra references: SHA256-shaped local identity and direct DRS URL pointer identity. A manifest bootstrap workflow remains useful, but it can build on the same lower-level `add-ref`/resolver work instead of being treated as a separate large subsystem. +The Terra remote endpoint used for service-health checks should be the Terra DRS service base URL, for example `https://data.terra.bio` in production. That base URL supports the GA4GH DRS service-info route at `/ga4gh/drs/v1/service-info`. DRSHub remains relevant for resolving Terra DRS URIs into access information, but it is a resolver service rather than the service-info host for `git drs ping`. + ## Background Gregor project users want a Git repository that stores project structure, manifests, analysis code, and `git-drs` pointer files while leaving controlled-access payload bytes on AnVIL. Users should be able to clone normal Git history, review which data objects are referenced, authenticate through AnVIL/Terra access controls, and hydrate only selected paths with commands such as: @@ -95,7 +107,7 @@ Example target CLI: ```bash git drs remote add terra anvil \ - --drs-endpoint https://drs.anvilproject.org \ + --drs-endpoint https://data.terra.bio \ --auth google-adc \ --mode read-only ``` diff --git a/docs/terra-anvil-tdd-tests.md b/docs/terra-anvil-tdd-tests.md index 899832d1..eb52ec7a 100644 --- a/docs/terra-anvil-tdd-tests.md +++ b/docs/terra-anvil-tdd-tests.md @@ -26,7 +26,7 @@ The test writes Git config entries equivalent to a future Terra remote: default-remote = anvil [drs "remote.anvil"] type = terra - endpoint = https://drs.anvilproject.org + endpoint = https://data.terra.bio auth = google-adc mode = read-only ``` diff --git a/internal/config/terra_remote_acceptance_test.go b/internal/config/terra_remote_acceptance_test.go index 79b7d410..e5a6ea81 100644 --- a/internal/config/terra_remote_acceptance_test.go +++ b/internal/config/terra_remote_acceptance_test.go @@ -18,7 +18,7 @@ func TestAcceptanceLoadTerraRemoteConfig(t *testing.T) { runGitConfig("drs.default-remote", "anvil") runGitConfig("drs.remote.anvil.type", "terra") - runGitConfig("drs.remote.anvil.endpoint", "https://drs.anvilproject.org") + runGitConfig("drs.remote.anvil.endpoint", "https://data.terra.bio") runGitConfig("drs.remote.anvil.auth", "google-adc") runGitConfig("drs.remote.anvil.mode", "read-only") @@ -30,7 +30,7 @@ func TestAcceptanceLoadTerraRemoteConfig(t *testing.T) { if remote == nil { t.Fatalf("expected terra remote to load as a DRS remote") } - if got := remote.GetEndpoint(); got != "https://drs.anvilproject.org" { + if got := remote.GetEndpoint(); got != "https://data.terra.bio" { t.Fatalf("unexpected terra endpoint: %q", got) } } diff --git a/internal/remoteruntime/runtime_test.go b/internal/remoteruntime/runtime_test.go index 2b083439..db4c6644 100644 --- a/internal/remoteruntime/runtime_test.go +++ b/internal/remoteruntime/runtime_test.go @@ -64,7 +64,7 @@ func TestNewTerraRemoteContext(t *testing.T) { cfg, err := config.UpdateRemote(config.Remote("anvil"), config.RemoteSelect{ Terra: &config.TerraRemote{ - Endpoint: "https://drs.anvilproject.org", + Endpoint: "https://data.terra.bio", Auth: "google-adc", Mode: "read-only", }, @@ -80,7 +80,7 @@ func TestNewTerraRemoteContext(t *testing.T) { if gitCtx.RemoteType != config.TerraServerType { t.Fatalf("RemoteType = %q, want %q", gitCtx.RemoteType, config.TerraServerType) } - if gitCtx.Endpoint != "https://drs.anvilproject.org" { + if gitCtx.Endpoint != "https://data.terra.bio" { t.Fatalf("Endpoint = %q, want Terra endpoint", gitCtx.Endpoint) } if gitCtx.Client != nil { From 31ec98c5cdab0704db8303c9c9f819cd89675a9b Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 13 Jul 2026 10:35:35 -0700 Subject: [PATCH 06/66] Adds DRS uri <--> OID clarification --- docs/multi-drs-reference-identity-adr.md | 172 +++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/multi-drs-reference-identity-adr.md diff --git a/docs/multi-drs-reference-identity-adr.md b/docs/multi-drs-reference-identity-adr.md new file mode 100644 index 00000000..5ca681c1 --- /dev/null +++ b/docs/multi-drs-reference-identity-adr.md @@ -0,0 +1,172 @@ +# ADR 0005: Preserve source DRS identity for reference-first workflows across mixed DRS servers + +## Status + +Proposed + +## Context + +`git-drs` supports two different classes of object tracking: + +1. **Normal local-file tracking** + - The user tracks local bytes. + - The clean filter calculates the content SHA256. + - The Git pointer uses `oid sha256:`. + - Downstream hydration can use the default configured DRS remote to look up DRS records by SHA256. + +2. **Reference-first tracking** through `add-ref` and `add-url` + - The user references object content that already exists somewhere else. + - The repository may not have local payload bytes at reference creation time. + - The referenced object may live behind a DRS server or provider/storage URL that is not the default DRS server. + - Not every DRS server used by a repository can be assumed to support SHA256 lookup. + +Today several implementation paths still rely on a SHA256-shaped local object identity. This works for local-file tracking because content bytes are available and the default remote is expected to resolve SHA256 checksums. It is not sufficient for `add-ref` and `add-url` when the source server cannot resolve by SHA256 or when the source DRS URI is the only durable retrieval identity. + +The important distinction is: + +- **content SHA256** identifies bytes when known; +- **local pointer/cache OID** gives `git-drs` a compact cache key and Git LFS-compatible pointer shape; +- **source DRS URI** identifies the remote DRS record and may be the only reliable way to retrieve bytes from a non-default or non-checksum-indexed DRS server. + +A downstream user who clones the repository must be able to hydrate reference-first objects without relying on the default DRS server's checksum lookup when the original source server does not support it. + +## Decision + +Adopt a split identity model for all reference-first workflows: + +1. **Normal local-file tracking remains content-SHA256 driven.** + - The clean filter calculates the real content SHA256 from local bytes. + - Pointers for normal tracked files continue to use `oid sha256:`. + - The default `remote` DRS server is assumed to support SHA256 lookup for these objects. + - Downstream users hydrate these files by checksum lookup against the default remote. + +2. **`add-ref` must preserve the original DRS URI as canonical source identity.** + - `git drs add-ref ` must store the input DRS URI as first-class metadata. + - If the source DRS object provides a real SHA256, store it as content metadata. + - If the source DRS object does not provide SHA256, do not fabricate a content checksum. + - Use a SHA256-shaped local/cache OID only as local identity when needed. + - Downstream hydration must resolve by the stored DRS URI when checksum lookup is unavailable or not appropriate. + +3. **`add-url` must preserve a canonical retrievable source reference.** + - If the input is a DRS URI, store that DRS URI as canonical source identity. + - If the input is a provider URL such as `s3://...`, store that provider URL as source access metadata and preserve enough remote/provider context to retrieve it later. + - If a real SHA256 is supplied through `--sha256`, store it as content metadata. + - If SHA256 is unknown, derive only a local placeholder/cache OID; mark it as non-content-derived. + - Downstream hydration must use the stored source reference rather than assuming checksum lookup will work. + +4. **Do not use DRS-URI-derived SHA256 values as content checksums.** + - A hash of `drs://...` is a local/cache key, not a byte checksum. + - Download validation must compare against a real content SHA256 only when one is known. + - If only DRS URI identity is known, validation should use size and DRS/provider metadata where available, and should clearly report that content SHA256 validation was unavailable. + +5. **Pointer/cache compatibility remains SHA256-shaped internally.** + - Existing fanout paths and many pointer workflows expect 64-hex OIDs. + - Reference-first objects may therefore use a derived local OID such as: + +```text +derived_oid = sha256("git-drs-source-ref:v1\nsource_uri=\nremote=\n") +``` + + - This derived OID is only a local identifier. + - It must be recorded with metadata that states its origin, for example `oid_kind = "derived-source-uri"`. + +## Required metadata model + +Reference-first metadata must make the following fields explicit: + +```text +local_oid SHA256-shaped local pointer/cache identity +local_oid_kind content-sha256 | derived-source-uri | placeholder +source_uri original drs:// URI or provider URL +source_type drs | s3 | gs | https | other +source_remote configured remote name, remote type, or resolver profile +content_sha256 real payload SHA256 when known +size object size when known +access_methods provider or DRS access metadata when available +``` + +The pointer may remain Git LFS-shaped for compatibility: + +```text +version https://git-lfs.github.com/spec/v1 +oid sha256: +size +``` + +But the sidecar/local metadata must be committed or otherwise made available to downstream users when it is required for hydration. A SHA256-shaped pointer alone is insufficient for non-checksum DRS servers unless `` is a real content SHA256 and a configured DRS/index can resolve it. + +A git-drs-specific pointer that embeds the DRS URI remains valid as an alternate representation: + +```text +version https://calypr.github.io/spec/v1 +oid drs://example/object +size +``` + +If this representation is used, the hydration path must preserve the OID type and resolve by DRS URI, not by checksum. + +## Hydration behavior + +Hydration should choose the retrieval strategy from metadata, in this order: + +1. **Source DRS URI path** + - If metadata contains `source_type = drs` and `source_uri`, resolve the DRS object through `source_remote` or the recorded resolver profile. + - Request an access URL from that source DRS server. + - Download to the local cache path for `local_oid`. + - Verify real SHA256 only if `content_sha256` is known. + +2. **Provider URL path** + - If metadata contains a provider URL and credentials/resolver context, retrieve through that provider path. + - Verify real SHA256 only if `content_sha256` is known. + +3. **Checksum lookup path** + - If `local_oid_kind = content-sha256` or `content_sha256` is known, lookup by checksum on the configured remote. + - This is the normal path for local-file tracking and remains supported for DRS servers that provide checksum lookup. + +4. **Failure path** + - If the only available identity is a derived/placeholder OID and there is no source URI/provider metadata, hydration must fail with an actionable error explaining that the repository lacks enough source identity to retrieve the object. + +## `add-ref` implications + +`add-ref` should no longer reduce a DRS reference to only a SHA256 pointer. + +Required behavior: + +1. Resolve the input DRS URI against the selected remote/resolver. +2. Capture returned size, checksums, access methods, and DRS object ID when available. +3. Persist the original input DRS URI as `source_uri`. +4. Persist the selected remote or resolver as `source_remote`. +5. Use real content SHA256 as `content_sha256` only if the DRS object reports it. +6. Use either: + - `local_oid = content_sha256` when a real SHA256 is known and policy permits checksum identity; or + - `local_oid = sha256(namespaced normalized source_uri + remote context)` when SHA256 is missing or when the source server cannot support checksum lookup. +7. Ensure downstream hydration resolves by `source_uri` when `source_remote` does not support SHA256 lookup. + +## `add-url` implications + +`add-url` should treat its input URL as source identity, not merely as a seed for a placeholder OID. + +Required behavior: + +1. Inspect the source object for size and metadata where possible. +2. Preserve the input object URL as `source_uri` or provider source metadata. +3. Preserve the selected remote/provider resolver context. +4. Use `--sha256` as real `content_sha256` only when supplied by the user or verified from trusted provider metadata. +5. If SHA256 is unknown, derive a local OID from namespaced source metadata and mark it as non-content-derived. +6. Ensure downstream hydration retrieves from the stored source URI/provider metadata rather than assuming checksum lookup. + +## Consequences + +- Normal local-file tracking remains compatible with existing SHA256 lookup behavior. +- `add-ref` and `add-url` become safe for multi-DRS repositories where some servers cannot look up by SHA256. +- Downstream users can hydrate reference-first objects because the original source identity is preserved. +- Cache paths remain compact and SHA256-shaped without confusing derived local IDs for payload checksums. +- Download verification must become metadata-aware: real SHA256 validation is required when known, but a derived local OID must never be treated as a byte checksum. +- Repository metadata must become part of the clone/hydration contract for reference-first objects. + +## Non-goals + +- Do not require every DRS server to implement SHA256 lookup. +- Do not require downloading payload bytes during `add-ref` or `add-url` solely to compute SHA256. +- Do not treat hashes of DRS URIs or provider URLs as real content checksums. +- Do not remove the existing SHA256-based workflow for normal local-file tracking. From 481c89f1a0a98e30b090cf10408acd3c6fce0175 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 13 Jul 2026 13:18:00 -0700 Subject: [PATCH 07/66] clarifies pointer file usage --- cmd/addref/add-ref.go | 41 ++++++++++++- cmd/addref/add-ref_test.go | 29 +++++++++- cmd/addurl/local_state.go | 22 +++++-- cmd/addurl/main_test.go | 3 + cmd/addurl/run.go | 7 ++- cmd/pull/main.go | 14 ++++- docs/adding-s3-files.md | 7 ++- docs/commands.md | 5 +- docs/pointer-files.md | 106 ++++++++++++++++++++++++++++++++++ internal/lfs/inventory.go | 32 ++++++++-- internal/transfer/download.go | 33 +++++++++++ 11 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 docs/pointer-files.md diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 614f8e37..0e1a7ad0 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -2,14 +2,20 @@ package addref import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" "github.com/calypr/git-drs/internal/config" "github.com/calypr/git-drs/internal/drslog" + "github.com/calypr/git-drs/internal/drsobject" + "github.com/calypr/git-drs/internal/gitrepo" "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/remoteruntime" + drsapi "github.com/calypr/syfon/apigen/client/drs" + "github.com/calypr/syfon/client/hash" "github.com/spf13/cobra" ) @@ -55,8 +61,21 @@ var Cmd = &cobra.Command{ os.MkdirAll(dirPath, os.ModePerm) } - err = lfs.CreateLfsPointer(&obj, dstPath) - return err + oid := addRefLocalOID(drsUri, remoteName, &obj) + if hasContentSHA256(&obj) { + if err := lfs.CreateLfsPointerWithOID(&obj, dstPath, oid); err != nil { + return err + } + } else if err := lfs.CreateDRSPointer(&obj, dstPath, drsUri); err != nil { + return err + } + if obj.SelfUri == "" { + obj.SelfUri = drsUri + } + if err := drsobject.WriteObject(gitrepo.DRSObjectsPath, &obj, oid); err != nil { + return fmt.Errorf("write source DRS metadata: %w", err) + } + return nil }, } @@ -64,3 +83,21 @@ func init() { Cmd.Flags().StringVarP(&remote, "remote", "r", "", "target remote DRS server (default: default_remote)") Cmd.Flags().StringVar(&remoteType, "remote-type", "", "resolver remote type for DRS references (for example: terra)") } + +func hasContentSHA256(obj *drsapi.DrsObject) bool { + return obj != nil && drsobject.NormalizeChecksum(hash.ConvertDrsChecksumsToHashInfo(obj.Checksums).SHA256) != "" +} + +func addRefLocalOID(sourceURI string, remoteName config.Remote, obj *drsapi.DrsObject) string { + if obj != nil { + if sha := drsobject.NormalizeChecksum(hash.ConvertDrsChecksumsToHashInfo(obj.Checksums).SHA256); sha != "" { + return sha + } + } + return derivedSourceOID(sourceURI, string(remoteName)) +} + +func derivedSourceOID(sourceURI, remoteName string) string { + sum := sha256.Sum256([]byte("git-drs-source-ref:v1\nsource_uri=" + sourceURI + "\nremote=" + remoteName + "\n")) + return hex.EncodeToString(sum[:]) +} diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index b8087f8a..4879e43b 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -12,7 +12,7 @@ import ( func TestCreateLfsPointer(t *testing.T) { obj := &drsapi.DrsObject{ Size: 10, - Checksums: []drsapi.Checksum{{Type: "sha256", Checksum: "abc"}}, + Checksums: []drsapi.Checksum{{Type: "sha256", Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}, } path := filepath.Join(t.TempDir(), "pointer") if err := lfs.CreateLfsPointer(obj, path); err != nil { @@ -40,3 +40,30 @@ func TestCreateLfsPointer_NoSHA256(t *testing.T) { t.Fatalf("expected error for missing sha256") } } + +func TestAddRefLocalOIDUsesDerivedSourceWhenSHA256Missing(t *testing.T) { + obj := &drsapi.DrsObject{Checksums: []drsapi.Checksum{{Type: "md5", Checksum: "md5"}}} + oid := addRefLocalOID("drs://example.org/object-1", "source", obj) + if len(oid) != 64 { + t.Fatalf("expected sha256-shaped derived oid, got %q", oid) + } + if oid != derivedSourceOID("drs://example.org/object-1", "source") { + t.Fatalf("expected derived source oid, got %s", oid) + } +} + +func TestCreateDRSPointerPreservesSourceURI(t *testing.T) { + obj := &drsapi.DrsObject{Size: 42} + path := filepath.Join(t.TempDir(), "pointer") + if err := lfs.CreateDRSPointer(obj, path, "drs://example.org/object-1"); err != nil { + t.Fatalf("CreateDRSPointer error: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read pointer: %v", err) + } + expected := "version https://calypr.github.io/spec/v1\noid drs://example.org/object-1\nsize 42\n" + if string(data) != expected { + t.Fatalf("pointer mismatch: expected %q, got %q", expected, string(data)) + } +} diff --git a/cmd/addurl/local_state.go b/cmd/addurl/local_state.go index d76d3a56..982bd7ae 100644 --- a/cmd/addurl/local_state.go +++ b/cmd/addurl/local_state.go @@ -21,9 +21,10 @@ import ( ) type addURLDrsFile struct { - Name string - Size int64 - Oid string + Name string + Size int64 + Oid string + ContentSHA256 string } func drsobjectBuilder(bucket, organization, project, storagePrefix string) drsobject.Builder { @@ -43,9 +44,18 @@ func writeAddURLDrsObject(builder drsobject.Builder, file addURLDrsFile, objectP drsObj.Size = file.Size } else { drsID := uuid.NewSHA1(drsobject.UUIDNamespace, []byte(fmt.Sprintf("%s:%s", builder.Project, drsobject.NormalizeOid(file.Oid)))).String() - drsObj, err = builder.Build(file.Name, file.Oid, file.Size, drsID) - if err != nil { - return nil, fmt.Errorf("error building DRS object for oid %s: %w", file.Oid, err) + if file.ContentSHA256 != "" { + drsObj, err = builder.Build(file.Name, file.ContentSHA256, file.Size, drsID) + if err != nil { + return nil, fmt.Errorf("error building DRS object for oid %s: %w", file.Oid, err) + } + } else { + drsObj = &drsapi.DrsObject{ + Id: drsID, + SelfUri: objectPath, + Size: file.Size, + Name: &file.Name, + } } } diff --git a/cmd/addurl/main_test.go b/cmd/addurl/main_test.go index 175b85d8..0c4ebee8 100644 --- a/cmd/addurl/main_test.go +++ b/cmd/addurl/main_test.go @@ -143,6 +143,9 @@ func TestRunAddURL_WritesPointerAndLFSObject(t *testing.T) { if got := (*drsObject.AccessMethods)[0].AccessUrl.Url; got != "s3://bucket/path/to/file.bin" { t.Fatalf("unexpected access URL: %s", got) } + if len(drsObject.Checksums) != 0 { + t.Fatalf("expected unknown sha256 add-url metadata not to fabricate checksums, got %+v", drsObject.Checksums) + } } func TestPlaceholderOIDForUnknownSHA(t *testing.T) { diff --git a/cmd/addurl/run.go b/cmd/addurl/run.go index 4c440557..572beb0c 100644 --- a/cmd/addurl/run.go +++ b/cmd/addurl/run.go @@ -137,9 +137,10 @@ func (s *AddURLService) Run(cmd *cobra.Command, args []string) error { builder := drsobjectBuilder(scope.Bucket, org, project, scope.Prefix) file := addURLDrsFile{ - Name: input.path, - Size: objectInfo.SizeBytes, - Oid: oid, + Name: input.path, + Size: objectInfo.SizeBytes, + Oid: oid, + ContentSHA256: input.sha256, } if _, err := writeAddURLDrsObject(builder, file, input.objectURL); err != nil { return fmt.Errorf("write local DRS object: %w", err) diff --git a/cmd/pull/main.go b/cmd/pull/main.go index 0b2a848e..660c3f97 100644 --- a/cmd/pull/main.go +++ b/cmd/pull/main.go @@ -183,7 +183,12 @@ var Cmd = &cobra.Command{ continue } } - if err := internaltransfer.DownloadToCachePath(downloadCtx, drsCtx, f.Oid, dstPath); err != nil { + if isDRSPointerOID(f.Oid) { + if err := internaltransfer.DownloadDRSURIToCachePath(downloadCtx, drsCtx, f.Oid, dstPath); err != nil { + debugCtx := buildPullDownloadDebugContext(ctx, drsCtx, f.Oid) + return fmt.Errorf("failed to download DRS URI %s to %s: %w\npull-debug: %s", f.Oid, dstPath, err, debugCtx) + } + } else if err := internaltransfer.DownloadToCachePath(downloadCtx, drsCtx, f.Oid, dstPath); err != nil { debugCtx := buildPullDownloadDebugContext(ctx, drsCtx, f.Oid) return fmt.Errorf("failed to download oid %s to %s: %w\npull-debug: %s", f.Oid, dstPath, err, debugCtx) } @@ -231,6 +236,11 @@ func collectPointerFiles(inventory map[string]lfs.LfsFileInfo, patterns []string return files } +func isDRSPointerOID(oid string) bool { + oid = strings.TrimSpace(oid) + return strings.HasPrefix(oid, "//") || strings.HasPrefix(strings.ToLower(oid), "drs://") +} + func progressContextForPointer(ctx context.Context, progress *internaltransfer.PullProgressRenderer, file pointerFile) context.Context { ctx = sycommon.WithOid(ctx, file.Name) return sycommon.WithProgress(ctx, func(ev sycommon.ProgressEvent) error { @@ -295,7 +305,7 @@ func inspectCachedObject(path, expectedOID string, expectedSize int64) (cachedOb if expectedSize <= 0 && info.Size() <= 0 { return state, nil } - if strings.TrimSpace(expectedOID) == "" { + if strings.TrimSpace(expectedOID) == "" || strings.HasPrefix(strings.TrimSpace(expectedOID), "//") || strings.HasPrefix(strings.ToLower(strings.TrimSpace(expectedOID)), "drs://") { state.complete = true return state, nil } diff --git a/docs/adding-s3-files.md b/docs/adding-s3-files.md index 0151d57a..0bf1d8d2 100644 --- a/docs/adding-s3-files.md +++ b/docs/adding-s3-files.md @@ -74,9 +74,10 @@ If SHA256 is unknown, omit `--sha256`. Behavior: 1. `add-url` performs object metadata lookup (HEAD/attributes). -2. A deterministic placeholder OID is derived from remote object metadata. -3. A pointer file and local DRS metadata are written. -4. `git drs push` performs metadata registration. +2. A deterministic placeholder/local OID is derived from remote object metadata. +3. A pointer file and local DRS metadata are written; the placeholder is not recorded as a content checksum. +4. The provider URL/source metadata remains the retrieval identity until content is downloaded. +5. `git drs push` performs metadata registration. ```bash git drs track "data/*.bin" diff --git a/docs/commands.md b/docs/commands.md index a588a7d4..3f4d35dd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -241,6 +241,8 @@ Notes: ## Provider/Object Reference Workflows +For details on pointer file formats and lifecycle state, see [Pointer Files and Reference State](pointer-files.md). + ### `git drs add-url [path]` Create a pointer plus local DRS metadata for an object that already exists in provider storage. @@ -256,11 +258,12 @@ Notes: - object-key mode resolves against the configured bucket scope - explicit provider URL mode remains supported - `--scheme` is required for object-key mode +- when `--sha256` is omitted, the pointer uses a derived local/cache OID and source URL metadata remains the retrieval identity - registration happens later on `git drs push` ### `git drs add-ref ` -Add a local pointer file for an existing DRS object. +Add a local pointer file for an existing DRS object. If the source DRS object has a SHA256 checksum, the pointer can use that checksum; otherwise the pointer preserves the source `drs://...` URI directly so hydration can resolve by DRS identity instead of checksum lookup. ```bash git drs add-ref drs://example/object-id data/object.bin diff --git a/docs/pointer-files.md b/docs/pointer-files.md new file mode 100644 index 00000000..5783fe9a --- /dev/null +++ b/docs/pointer-files.md @@ -0,0 +1,106 @@ +# Pointer Files and Reference State + +`git-drs` keeps large payload bytes out of Git history by committing small pointer files. A pointer records enough identity to let `git-drs` find or rebuild the payload later, while the payload itself lives in local cache, provider storage, or behind a DRS server. + +This page documents the pointer shapes used by reference-first workflows and how state changes as commands create references, download content, and obtain temporary access URLs (the resolved "real" download URLs used for the transfer). + +## Pointer file shapes + +### Content SHA256 pointer + +When the real payload SHA256 is known, `git-drs` uses a Git LFS-compatible pointer: + +```text +version https://git-lfs.github.com/spec/v1 +oid sha256: +size +``` + +For normal tracked-file workflows, `` is the real content SHA256. For metadata-first workflows, it may be a SHA256-shaped local/cache OID only when separate metadata preserves the retrievable source identity. + +### DRS URI pointer + +When `add-ref` resolves a DRS object that does not provide a real SHA256, `git-drs` preserves the source identity directly in a git-drs pointer: + +```text +version https://calypr.github.io/spec/v1 +oid drs:/// +size +``` + +The DRS URI is the retrieval identity. It is not a content checksum. Cache paths can still use a deterministic SHA256-shaped key derived from the DRS URI, but validation must not compare downloaded bytes against that derived key as if it were a content SHA256. + +## State model + +The same path can move through these states: + +| State | Worktree file | Local cache | Source metadata | Retrieval behavior | +|---|---|---|---|---| +| Pointer only | Pointer text is present at the tracked path | Payload may be absent | DRS URI, provider URL, or checksum metadata identifies the source | `git drs pull` can hydrate the path later | +| Cached payload | Pointer text remains in Git/index history, and payload bytes exist in `.git/lfs/objects/...` | Payload bytes are present | Same source metadata remains available | checkout/smudge can replace pointer text with payload bytes | +| Hydrated worktree | The tracked path contains payload bytes | Payload bytes are present | Source metadata remains the retrieval contract | Future clean/filter operations can write the pointer back to Git | + +A DRS/provider access URL is usually not stable source identity. It is a temporary, resolved "real" download URL created from the stable DRS URI or provider reference when content is downloaded. + +## Command-created state changes + +### `git drs add-ref ` + +`add-ref` creates a reference to an existing DRS object without downloading payload bytes solely to compute a checksum. + +1. The command resolves the input DRS URI through the selected remote/resolver. +2. If the DRS object reports a real SHA256, `git-drs` writes a SHA256 pointer whose OID is that content checksum. +3. If the DRS object does not report a real SHA256, `git-drs` writes a DRS URI pointer whose OID preserves the original `drs://...` source reference. +4. The fetched DRS metadata is stored locally under a deterministic local/cache OID. +5. The worktree path is in the **pointer only** state until content is hydrated. + +Example pointer when SHA256 is unavailable: + +```text +version https://calypr.github.io/spec/v1 +oid drs://example.org/object-1 +size 123456 +``` + +### `git drs add-url [path]` + +`add-url` creates a reference to an object that already exists in provider storage. + +1. The command inspects the provider object through the configured remote. +2. The input provider URL or resolved provider URL is preserved as source access metadata. +3. If `--sha256 ` is supplied or a trusted SHA256 is discovered, that value is stored as content metadata and used as the pointer/cache OID. +4. If SHA256 is unknown, `git-drs` derives a placeholder/local OID from source metadata and writes a pointer with that SHA256-shaped local OID; it does **not** record the placeholder as a content checksum. +5. The worktree path is in the **pointer only** state until content is hydrated. + +Example pointer when SHA256 is unknown: + +```text +version https://git-lfs.github.com/spec/v1 +oid sha256: +size 123456 +``` + +In that case, `` is a local/cache identifier, not a payload checksum. + +### `git drs pull` + +`pull` changes pointer-only paths into hydrated content when payload bytes are missing locally. + +1. The command scans tracked pointer files in the current checkout. +2. For a content SHA256 pointer, it can look up a scoped DRS record by checksum and request a temporary access URL from that DRS record. +3. For a DRS URI pointer, it resolves the stored `drs://...` source URI, requests a temporary access URL for that DRS object, and downloads through the resolved URL. +4. For provider-backed references, it uses the stored provider/source metadata to retrieve content when available. +5. Downloaded bytes are written to the local cache path. +6. If a real content SHA256 is known, the downloaded bytes are validated against it. If only a DRS URI or derived local OID is known, validation is limited to size and available DRS/provider metadata. +7. The worktree file is checked out from the cache, moving it to the **hydrated worktree** state. + +The temporary access URL (the resolved "real" URL) created during this process is a download mechanism, not the durable identity committed in Git. The durable identity remains the pointer plus source metadata: content SHA256 when known, DRS URI for DRS references, or provider URL/source metadata for provider references. + +## Practical review guidance + +When reviewing pointer changes: + +- `oid sha256:<64-hex>` means either a real content SHA256 or a SHA256-shaped local OID; check source metadata when the object came from `add-url` without `--sha256`. +- `oid drs://...` means the pointer itself preserves the source DRS URI and hydration should resolve by DRS URI, not by checksum lookup. +- A hash derived from a DRS URI or provider URL must not be used as a content checksum. +- Temporary access URLs may be created during hydration, but they should not replace the durable source identity in committed pointer state. diff --git a/internal/lfs/inventory.go b/internal/lfs/inventory.go index bf136913..a197f78d 100644 --- a/internal/lfs/inventory.go +++ b/internal/lfs/inventory.go @@ -560,17 +560,37 @@ func CreateLfsPointer(drsObj *drsapi.DrsObject, dst string) error { if shaSum == "" { return fmt.Errorf("no sha256 checksum found for DRS object") } + return CreateLfsPointerWithOID(drsObj, dst, shaSum) +} - // create pointer file content +// CreateLfsPointerWithOID writes a Git LFS pointer using an explicit local/cache +// oid. The oid may be a real content SHA256 or a derived source-identity key. +func CreateLfsPointerWithOID(drsObj *drsapi.DrsObject, dst string, oid string) error { + oid = strings.TrimPrefix(strings.TrimSpace(oid), "sha256:") + if !sha256OIDRe.MatchString(oid) { + return fmt.Errorf("oid %q is not a valid sha256-shaped value", oid) + } pointerContent := "version https://git-lfs.github.com/spec/v1\n" - pointerContent += fmt.Sprintf("oid sha256:%s\n", shaSum) + pointerContent += fmt.Sprintf("oid sha256:%s\n", strings.ToLower(oid)) pointerContent += fmt.Sprintf("size %d\n", drsObj.Size) - - // write to file - err := os.WriteFile(dst, []byte(pointerContent), 0644) - if err != nil { + if err := os.WriteFile(dst, []byte(pointerContent), 0644); err != nil { return fmt.Errorf("failed to write LFS pointer file: %w", err) } + return nil +} +// CreateDRSPointer writes a git-drs pointer that preserves the retrievable DRS URI. +func CreateDRSPointer(drsObj *drsapi.DrsObject, dst string, drsURI string) error { + drsURI = strings.TrimSpace(drsURI) + if !strings.HasPrefix(strings.ToLower(drsURI), "drs://") { + return fmt.Errorf("DRS URI %q must start with drs://", drsURI) + } + pointerOID := "//" + drsURI[len("drs://"):] + pointerContent := "version https://calypr.github.io/spec/v1\n" + pointerContent += fmt.Sprintf("oid drs:%s\n", pointerOID) + pointerContent += fmt.Sprintf("size %d\n", drsObj.Size) + if err := os.WriteFile(dst, []byte(pointerContent), 0644); err != nil { + return fmt.Errorf("failed to write DRS pointer file: %w", err) + } return nil } diff --git a/internal/transfer/download.go b/internal/transfer/download.go index 70928b5c..33407a16 100644 --- a/internal/transfer/download.go +++ b/internal/transfer/download.go @@ -42,6 +42,39 @@ func AccessURLForHashScope(ctx context.Context, drsCtx *remoteruntime.GitContext return &accessURL, &match, nil } +func AccessURLForDRSURI(ctx context.Context, drsCtx *remoteruntime.GitContext, drsURI string) (*drsapi.AccessURL, *drsapi.DrsObject, error) { + if strings.HasPrefix(drsURI, "//") { + drsURI = "drs:" + drsURI + } + obj, err := drsCtx.Client.DRS().GetObject(ctx, drsURI) + if err != nil { + return nil, nil, err + } + if obj.AccessMethods == nil || len(*obj.AccessMethods) == 0 { + return nil, nil, fmt.Errorf("no access methods available for DRS object %s", obj.Id) + } + accessType := (*obj.AccessMethods)[0].Type + if accessType == "" { + return nil, nil, fmt.Errorf("no access type found in access method for DRS object %s", obj.Id) + } + accessURL, err := drsCtx.Client.DRS().GetAccessURL(ctx, obj.Id, string(accessType)) + if err != nil { + return nil, nil, err + } + return &accessURL, &obj, nil +} + +func DownloadDRSURIToCachePath(ctx context.Context, drsCtx *remoteruntime.GitContext, drsURI, cachePath string) error { + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return fmt.Errorf("mkdir for cache path: %w", err) + } + accessURL, obj, err := AccessURLForDRSURI(ctx, drsCtx, drsURI) + if err != nil { + return err + } + return downloadResolved(ctx, drsCtx, drsURI, cachePath, obj, accessURL) +} + func DownloadToCachePath(ctx context.Context, drsCtx *remoteruntime.GitContext, oid, cachePath string) error { if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { return fmt.Errorf("mkdir for cache path: %w", err) From bc81881cdcbc4758bb14bd9328c5b668d2388a8c Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 13 Jul 2026 14:06:13 -0700 Subject: [PATCH 08/66] adds sequence diagram --- docs/pointer-files.md | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/docs/pointer-files.md b/docs/pointer-files.md index 5783fe9a..00c77076 100644 --- a/docs/pointer-files.md +++ b/docs/pointer-files.md @@ -96,6 +96,139 @@ In that case, `` is a local/cache identifier, not a payload c The temporary access URL (the resolved "real" URL) created during this process is a download mechanism, not the durable identity committed in Git. The durable identity remains the pointer plus source metadata: content SHA256 when known, DRS URI for DRS references, or provider URL/source metadata for provider references. +## Primary remote DRS server state + +The **primary remote DRS server** is the configured `drs.default-remote` or the remote passed to a command. For reference-first flows, distinguish three places where state can exist: + +- **Git state:** committed pointer text at the repository path. +- **Local git-drs state:** sidecar DRS object JSON under the repo's local DRS metadata directory, plus optional local LFS cache bytes. +- **Primary remote DRS server state:** Syfon/Gen3 DRS records, scoped to the configured organization/project, and any remote storage object those records point at. + +`add-ref` and `add-url` are intentionally metadata-first commands. They create Git and local git-drs state first; they do not necessarily create or mutate the primary remote DRS server until a later `git drs push` or an explicit server-side copy/register operation. + +### Sequence: pointer and DRS server state for `add-ref` / `add-url` + +The following sequence diagram shows the expected pointer-file changes, local +metadata/cache changes, and primary remote DRS server changes for the +reference-first `add-ref`/`add-url` flows. If a caller says `add-reg` in this +context, treat it as the registration side of the same reference-first flow: +the server-side record is created or refined only when metadata is pushed or +explicitly registered, not when the local pointer is first written. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Git as Git worktree/index + participant Local as Local git-drs metadata/cache + participant Remote as Primary remote DRS server + participant Source as Source DRS/provider object + + rect rgb(238, 248, 255) + note over User,Source: add-ref: reference an existing DRS URI + User->>Remote: git drs add-ref drs://source/object data/file + Remote->>Source: Resolve DRS URI / fetch DRS object metadata + Source-->>Remote: DRS object metadata, size, access methods, optional sha256 + Remote-->>User: Resolved DRS object metadata + alt Resolved object includes real sha256 + User->>Git: Write pointer oid sha256: + User->>Local: Store DRS metadata keyed by real sha256 + else Resolved object does not include real sha256 + User->>Git: Write git-drs pointer oid drs://source/object + User->>Local: Store DRS metadata keyed by derived DRS/local cache key + end + note over Remote: No new scoped DRS record is required merely because add-ref wrote a local pointer. + end + + rect rgb(248, 255, 238) + note over User,Source: add-url: reference an existing provider object + User->>Remote: git drs add-url s3://bucket/key data/file [--sha256] + Remote->>Source: Inspect provider object using remote bucket credentials/scope + Source-->>Remote: Provider URL, size, ETag/metadata, optional trusted sha256 + Remote-->>User: Inspected object metadata + alt Real sha256 supplied or trusted + User->>Git: Write pointer oid sha256: + User->>Local: Store local DRS metadata with checksum sha256= + else Real sha256 unknown + User->>Local: Calculate placeholder/local OID from provider metadata + User->>Git: Write pointer oid sha256: + User->>Local: Store provider access URL and size without checksum= + end + note over Remote: Inspection does not itself create the durable DRS record for this repo scope. + end + + rect rgb(255, 248, 238) + note over User,Remote: Push/register metadata with primary remote + User->>Remote: git drs push / register metadata for reachable pointers + alt Pointer/local metadata has real sha256 + Remote->>Remote: Upsert scoped DRS record with sha256, size, and access method + else Only DRS URI/provider source identity is known + Remote->>Remote: Upsert metadata preserving source URI/provider access, size, and no fake sha256 + end + end + + rect rgb(255, 238, 248) + note over User,Source: Later hydration discovers real content sha256 + User->>Remote: git drs pull resolves DRS URI or source metadata + Remote->>Source: Request temporary access URL / download bytes + Source-->>Local: Store downloaded payload bytes in cache/worktree + Local->>Local: Calculate real sha256 over downloaded bytes + Local->>Local: Store/refine local checksum metadata sha256= + opt User intentionally rewrites pointer metadata + User->>Git: Replace DRS/placeholder pointer with oid sha256: + end + opt User pushes refined metadata + User->>Remote: git drs push / register refined checksum metadata + Remote->>Remote: Store real sha256 as checksum; keep DRS/provider retrieval identity + Remote->>Remote: Ensure placeholder or derived local OID is not advertised as checksum + end + end +``` + +### After `git drs add-ref ` + +Expected state immediately after `add-ref` succeeds: + +| Location | Expected state | +|---|---| +| Git/worktree | A pointer file exists at ``. If the resolved DRS object has a real SHA256 checksum, the pointer uses `oid sha256:`; otherwise it uses a git-drs DRS URI pointer that preserves the input DRS URI. | +| Local git-drs metadata | The resolved DRS object returned by the selected remote/resolver is stored locally under the local OID used for the pointer/cache lookup. If the DRS object omitted `self_uri`, the input DRS URI is retained as `self_uri`. | +| Local payload cache | No payload download is required just to create the reference. The cache may still be empty for this object. | +| Primary remote DRS server | No new record is required merely because `add-ref` ran. The primary remote must be able to resolve the source DRS URI for future hydration, but `add-ref` should be treated as creating a local reference to an existing DRS record, not as copying or registering that record into a new project scope. | + +If the input DRS URI points at another DRS authority or resolver, the primary remote's durable state after `add-ref` is still unchanged unless the server itself implements and is asked to persist a proxy/copy record. The committed reference remains valid because the pointer/local metadata preserve the source DRS URI. + +### After `git drs add-url [path]` + +Expected state immediately after `add-url` succeeds: + +| Location | Expected state | +|---|---| +| Git/worktree | A Git LFS-shaped pointer file exists at `[path]`. With `--sha256`, its OID is the real content SHA256. Without `--sha256`, its OID is a deterministic SHA256-shaped placeholder/local OID derived from inspected provider metadata. | +| Local git-drs metadata | A local DRS object is written with the resolved provider URL as an access method. When `--sha256` is provided, the local DRS object includes a `sha256` checksum. When SHA256 is unknown, the placeholder/local OID is **not** written as a checksum. | +| Local payload cache | No payload upload or download is required. The object bytes are expected to already exist at the provider URL inspected by the remote. | +| Primary remote DRS server | No DRS record is required immediately. On a later `git drs push`, git-drs can bulk-register metadata for the pointer into the primary remote scope. For known-SHA objects, the remote record should include the real SHA256 and size. For unknown-SHA objects, the remote record must preserve the provider access URL and size, and must not claim that the placeholder/local OID is a real content checksum. | + +A primary remote may already know about the provider object because of its bucket credential/scope configuration. That inspection ability is not the same thing as a durable DRS record for the repository object; durable registration is a push-time concern. + +### After a DRS URI is downloaded and a real SHA256 is calculated + +Downloading through a DRS URI has two separate effects: + +1. It hydrates local state by resolving the stable DRS URI to a temporary access URL and writing payload bytes into the local cache/worktree. +2. It may reveal the real content SHA256 if the downloaded bytes are hashed locally. + +Expected state after the real SHA256 is known: + +| Location | Expected state | +|---|---| +| Git/worktree | Existing committed pointer identity should not silently change during download. A DRS URI pointer remains a DRS URI pointer until the user/tool intentionally rewrites and commits it as a content-SHA pointer. | +| Local payload cache | The downloaded bytes exist in the local cache. If a previous cache key was derived from a DRS URI or placeholder, that key remains a local retrieval/cache key, not proof of content identity. Implementations may also add an alias/cache entry keyed by the real SHA256, but should avoid losing the source DRS URI mapping. | +| Local git-drs metadata | The real SHA256 can be added as checksum metadata alongside the preserved DRS URI/provider source identity. The DRS URI/provider URL remains the durable retrieval contract unless and until a remote record with the real checksum is registered. | +| Primary remote DRS server | Merely downloading and hashing bytes locally should not mutate the primary remote. If the repository later pushes an updated metadata record, the primary remote should have a scoped DRS record whose `sha256` is the real content checksum, whose `size` matches the downloaded payload, and whose access methods still resolve to the original DRS/provider source or to a managed copied object. The remote should not retain a placeholder/local OID as a checksum once the real SHA256 is known. | + +When a placeholder or DRS-derived local OID is replaced in metadata by a real SHA256, treat it as a metadata refinement, not as evidence that the original source identity was wrong. The safe final server state is: **real content SHA256 for checksum-based lookup, stable DRS URI/provider access metadata for retrieval, and no placeholder/local OID advertised as a content checksum.** + ## Practical review guidance When reviewing pointer changes: From 10d7c2c31b20ada3feb3b6c02dddfdfb5e368ffa Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 14 Jul 2026 12:26:19 -0700 Subject: [PATCH 09/66] typo --- docs/pointer-files.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pointer-files.md b/docs/pointer-files.md index 00c77076..099eb48b 100644 --- a/docs/pointer-files.md +++ b/docs/pointer-files.md @@ -179,7 +179,7 @@ sequenceDiagram end opt User pushes refined metadata User->>Remote: git drs push / register refined checksum metadata - Remote->>Remote: Store real sha256 as checksum; keep DRS/provider retrieval identity + Remote->>Remote: Store real sha256 as checksum, keep DRS/provider retrieval identity Remote->>Remote: Ensure placeholder or derived local OID is not advertised as checksum end end From 23c9b9613af0abc38fcb2b84b63641fb329dc030 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 15 Jul 2026 12:17:06 -0700 Subject: [PATCH 10/66] add-ref contacts the configured source authority --- cmd/addref/add-ref.go | 88 +++++++++++++++++++++++++++++++++++++- cmd/addref/add-ref_test.go | 57 ++++++++++++++++++++++++ docs/pointer-files.md | 31 ++++++++------ 3 files changed, 162 insertions(+), 14 deletions(-) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 0e1a7ad0..5fb0eecb 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -5,8 +5,10 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net/url" "os" "path/filepath" + "strings" "github.com/calypr/git-drs/internal/config" "github.com/calypr/git-drs/internal/drslog" @@ -15,6 +17,7 @@ import ( "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/remoteruntime" drsapi "github.com/calypr/syfon/apigen/client/drs" + syclient "github.com/calypr/syfon/client" "github.com/calypr/syfon/client/hash" "github.com/spf13/cobra" ) @@ -50,7 +53,7 @@ var Cmd = &cobra.Command{ return err } - obj, err := client.Client.DRS().GetObject(context.Background(), drsUri) + obj, err := resolveAddRefObject(context.Background(), cfg, remoteName, client, drsUri) if err != nil { return err } @@ -101,3 +104,86 @@ func derivedSourceOID(sourceURI, remoteName string) string { sum := sha256.Sum256([]byte("git-drs-source-ref:v1\nsource_uri=" + sourceURI + "\nremote=" + remoteName + "\n")) return hex.EncodeToString(sum[:]) } + +type drsObjectGetter interface { + GetObject(context.Context, string) (drsapi.DrsObject, error) +} + +func resolveAddRefObject(ctx context.Context, cfg *config.Config, primaryRemote config.Remote, primary *remoteruntime.GitContext, drsURI string) (drsapi.DrsObject, error) { + objectID, sourceRemote, ok := sourceRemoteForDRSURI(cfg, primaryRemote, drsURI) + if !ok { + return primary.Client.DRS().GetObject(ctx, drsURI) + } + if sourceRemote == primaryRemote { + if sourceEndpoint, sourceOK := sourceEndpointForDRSURI(drsURI); sourceOK && !endpointMatchesDRSURI(primary.Endpoint, drsURI) { + getter, err := newAnonymousSourceDRSGetter(sourceEndpoint) + if err != nil { + return drsapi.DrsObject{}, err + } + return getter.GetObject(ctx, objectID) + } + return primary.Client.DRS().GetObject(ctx, objectID) + } + client, err := remoteruntime.New(cfg, sourceRemote, primary.Logger) + if err != nil { + return drsapi.DrsObject{}, err + } + return client.Client.DRS().GetObject(ctx, objectID) +} + +func sourceRemoteForDRSURI(cfg *config.Config, primaryRemote config.Remote, drsURI string) (objectID string, remoteName config.Remote, ok bool) { + u, err := url.Parse(strings.TrimSpace(drsURI)) + if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { + return drsURI, primaryRemote, false + } + objectID = strings.TrimPrefix(u.EscapedPath(), "/") + if objectID == "" { + return drsURI, primaryRemote, false + } + for name, selected := range cfg.Remotes { + remote := config.Config{Remotes: map[config.Remote]config.RemoteSelect{name: selected}}.GetRemote(name) + if remote == nil { + continue + } + endpoint, err := url.Parse(remote.GetEndpoint()) + if err != nil { + continue + } + if strings.EqualFold(endpoint.Host, u.Host) { + return objectID, name, true + } + } + return objectID, primaryRemote, true +} + +func sourceEndpointForDRSURI(drsURI string) (string, bool) { + u, err := url.Parse(strings.TrimSpace(drsURI)) + if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { + return "", false + } + return "https://" + u.Host, true +} + +func endpointMatchesDRSURI(endpoint string, drsURI string) bool { + u, err := url.Parse(strings.TrimSpace(drsURI)) + if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { + return false + } + endpointURL, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return false + } + return strings.EqualFold(endpointURL.Host, u.Host) +} + +func newAnonymousSourceDRSGetter(endpoint string) (drsObjectGetter, error) { + raw, err := syclient.New(endpoint) + if err != nil { + return nil, err + } + client, ok := raw.(*syclient.Client) + if !ok { + return nil, fmt.Errorf("unexpected syfon client type %T", raw) + } + return client.DRS(), nil +} diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index 4879e43b..94a684b6 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -1,11 +1,18 @@ package addref import ( + "context" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "github.com/calypr/git-drs/internal/config" + "github.com/calypr/git-drs/internal/drslog" "github.com/calypr/git-drs/internal/lfs" + "github.com/calypr/git-drs/internal/remoteruntime" drsapi "github.com/calypr/syfon/apigen/client/drs" ) @@ -67,3 +74,53 @@ func TestCreateDRSPointerPreservesSourceURI(t *testing.T) { t.Fatalf("pointer mismatch: expected %q, got %q", expected, string(data)) } } + +func TestResolveAddRefObjectUsesSourceAuthorityRemoteCredentials(t *testing.T) { + var primaryRequests int + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + primaryRequests++ + http.Error(w, "primary remote must not resolve source DRS URI", http.StatusTeapot) + })) + defer primary.Close() + + var sourceRequests int + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceRequests++ + if r.URL.Path != "/ga4gh/drs/v1/objects/object-1" { + t.Fatalf("unexpected source path: %s", r.URL.Path) + } + user, pass, ok := r.BasicAuth() + if !ok || user != "source-user" || pass != "source-pass" { + t.Fatalf("expected source basic auth credentials, got ok=%v user=%q pass=%q", ok, user, pass) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"object-1","self_uri":"drs://` + r.Host + `/object-1","size":42}`)) + })) + defer source.Close() + + cfg := &config.Config{ + DefaultRemote: "primary", + Remotes: map[config.Remote]config.RemoteSelect{ + "primary": {Local: &config.LocalRemote{BaseURL: primary.URL}}, + "source": {Local: &config.LocalRemote{BaseURL: source.URL, BasicUsername: "source-user", BasicPassword: "source-pass"}}, + }, + } + primaryCtx, err := remoteruntime.New(cfg, "primary", drslog.NewNoOpLogger()) + if err != nil { + t.Fatalf("create primary runtime: %v", err) + } + + obj, err := resolveAddRefObject(context.Background(), cfg, "primary", primaryCtx, "drs://"+strings.TrimPrefix(source.URL, "http://")+"/object-1") + if err != nil { + t.Fatalf("resolveAddRefObject: %v", err) + } + if obj.Id != "object-1" || obj.Size != 42 { + t.Fatalf("unexpected object: %+v", obj) + } + if sourceRequests != 1 { + t.Fatalf("expected one source request, got %d", sourceRequests) + } + if primaryRequests != 0 { + t.Fatalf("expected primary not to be contacted, got %d requests", primaryRequests) + } +} diff --git a/docs/pointer-files.md b/docs/pointer-files.md index 099eb48b..c6b2c2f5 100644 --- a/docs/pointer-files.md +++ b/docs/pointer-files.md @@ -48,7 +48,7 @@ A DRS/provider access URL is usually not stable source identity. It is a tempora `add-ref` creates a reference to an existing DRS object without downloading payload bytes solely to compute a checksum. -1. The command resolves the input DRS URI through the selected remote/resolver. +1. The client resolves the input DRS URI against the source authority/resolver named by the `drs://...` URI, using credentials appropriate for that source. A primary remote is only used for this metadata fetch when it is explicitly configured as the source resolver/proxy. 2. If the DRS object reports a real SHA256, `git-drs` writes a SHA256 pointer whose OID is that content checksum. 3. If the DRS object does not report a real SHA256, `git-drs` writes a DRS URI pointer whose OID preserves the original `drs://...` source reference. 4. The fetched DRS metadata is stored locally under a deterministic local/cache OID. @@ -121,15 +121,21 @@ sequenceDiagram actor User participant Git as Git worktree/index participant Local as Local git-drs metadata/cache + participant Source as Source DRS authority/resolver participant Remote as Primary remote DRS server - participant Source as Source DRS/provider object + participant Provider as Provider object rect rgb(238, 248, 255) note over User,Source: add-ref: reference an existing DRS URI - User->>Remote: git drs add-ref drs://source/object data/file - Remote->>Source: Resolve DRS URI / fetch DRS object metadata - Source-->>Remote: DRS object metadata, size, access methods, optional sha256 - Remote-->>User: Resolved DRS object metadata + User->>Source: git drs add-ref drs://source/object data/file + Source-->>User: DRS object metadata, size, access methods, optional sha256 + note over User,Source: Client resolves drs://source/object against the source authority/resolver using source credentials. + opt Primary remote is explicitly configured as source resolver/proxy + User->>Remote: Resolve drs://source/object through configured proxy + Remote->>Source: Fetch DRS object metadata using configured/delegated source credentials + Source-->>Remote: DRS object metadata + Remote-->>User: Resolved DRS object metadata + end alt Resolved object includes real sha256 User->>Git: Write pointer oid sha256: User->>Local: Store DRS metadata keyed by real sha256 @@ -141,10 +147,10 @@ sequenceDiagram end rect rgb(248, 255, 238) - note over User,Source: add-url: reference an existing provider object + note over User,Provider: add-url: reference an existing provider object User->>Remote: git drs add-url s3://bucket/key data/file [--sha256] - Remote->>Source: Inspect provider object using remote bucket credentials/scope - Source-->>Remote: Provider URL, size, ETag/metadata, optional trusted sha256 + Remote->>Provider: Inspect provider object using remote bucket credentials/scope + Provider-->>Remote: Provider URL, size, ETag/metadata, optional trusted sha256 Remote-->>User: Inspected object metadata alt Real sha256 supplied or trusted User->>Git: Write pointer oid sha256: @@ -169,9 +175,8 @@ sequenceDiagram rect rgb(255, 238, 248) note over User,Source: Later hydration discovers real content sha256 - User->>Remote: git drs pull resolves DRS URI or source metadata - Remote->>Source: Request temporary access URL / download bytes - Source-->>Local: Store downloaded payload bytes in cache/worktree + User->>Source: git drs pull resolves DRS URI or source metadata + Source-->>Local: Temporary access URL / downloaded payload bytes in cache/worktree Local->>Local: Calculate real sha256 over downloaded bytes Local->>Local: Store/refine local checksum metadata sha256= opt User intentionally rewrites pointer metadata @@ -194,7 +199,7 @@ Expected state immediately after `add-ref` succeeds: | Git/worktree | A pointer file exists at ``. If the resolved DRS object has a real SHA256 checksum, the pointer uses `oid sha256:`; otherwise it uses a git-drs DRS URI pointer that preserves the input DRS URI. | | Local git-drs metadata | The resolved DRS object returned by the selected remote/resolver is stored locally under the local OID used for the pointer/cache lookup. If the DRS object omitted `self_uri`, the input DRS URI is retained as `self_uri`. | | Local payload cache | No payload download is required just to create the reference. The cache may still be empty for this object. | -| Primary remote DRS server | No new record is required merely because `add-ref` ran. The primary remote must be able to resolve the source DRS URI for future hydration, but `add-ref` should be treated as creating a local reference to an existing DRS record, not as copying or registering that record into a new project scope. | +| Primary remote DRS server | No new record is required merely because `add-ref` ran. The client should resolve the source DRS URI through the source authority/resolver using source credentials for future hydration. The primary remote only needs to resolve that URI when it is explicitly configured as the source resolver/proxy; otherwise, `add-ref` should be treated as creating a local reference to an existing DRS record, not as copying or registering that record into a new project scope. | If the input DRS URI points at another DRS authority or resolver, the primary remote's durable state after `add-ref` is still unchanged unless the server itself implements and is asked to persist a proxy/copy record. The committed reference remains valid because the pointer/local metadata preserve the source DRS URI. From b535e9d58bd3534189c49398b9e9be6ad83ebedd Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 15 Jul 2026 12:49:16 -0700 Subject: [PATCH 11/66] Document multi-remote add-ref resolution --- cmd/addref/add-ref.go | 60 +++++++++------------ cmd/addref/add-ref_test.go | 106 +++++++++++++++++++++++++++++++++++++ docs/commands.md | 8 +++ docs/pointer-files.md | 12 ++--- 4 files changed, 142 insertions(+), 44 deletions(-) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 5fb0eecb..0a1e0a40 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -110,35 +110,43 @@ type drsObjectGetter interface { } func resolveAddRefObject(ctx context.Context, cfg *config.Config, primaryRemote config.Remote, primary *remoteruntime.GitContext, drsURI string) (drsapi.DrsObject, error) { - objectID, sourceRemote, ok := sourceRemoteForDRSURI(cfg, primaryRemote, drsURI) + objectID, sourceEndpoint, ok := parseDRSURIForSource(drsURI) if !ok { return primary.Client.DRS().GetObject(ctx, drsURI) } - if sourceRemote == primaryRemote { - if sourceEndpoint, sourceOK := sourceEndpointForDRSURI(drsURI); sourceOK && !endpointMatchesDRSURI(primary.Endpoint, drsURI) { - getter, err := newAnonymousSourceDRSGetter(sourceEndpoint) - if err != nil { - return drsapi.DrsObject{}, err - } - return getter.GetObject(ctx, objectID) + if sourceRemote, found := configuredRemoteForDRSURIHost(cfg, drsURI); found { + if sourceRemote == primaryRemote { + return primary.Client.DRS().GetObject(ctx, objectID) + } + client, err := remoteruntime.New(cfg, sourceRemote, primary.Logger) + if err != nil { + return drsapi.DrsObject{}, err } - return primary.Client.DRS().GetObject(ctx, objectID) + return client.Client.DRS().GetObject(ctx, objectID) } - client, err := remoteruntime.New(cfg, sourceRemote, primary.Logger) + getter, err := newSourceDRSGetter(sourceEndpoint) if err != nil { return drsapi.DrsObject{}, err } - return client.Client.DRS().GetObject(ctx, objectID) + return getter.GetObject(ctx, objectID) } -func sourceRemoteForDRSURI(cfg *config.Config, primaryRemote config.Remote, drsURI string) (objectID string, remoteName config.Remote, ok bool) { +func parseDRSURIForSource(drsURI string) (objectID string, endpoint string, ok bool) { u, err := url.Parse(strings.TrimSpace(drsURI)) if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { - return drsURI, primaryRemote, false + return "", "", false } objectID = strings.TrimPrefix(u.EscapedPath(), "/") if objectID == "" { - return drsURI, primaryRemote, false + return "", "", false + } + return objectID, "https://" + u.Host, true +} + +func configuredRemoteForDRSURIHost(cfg *config.Config, drsURI string) (config.Remote, bool) { + u, err := url.Parse(strings.TrimSpace(drsURI)) + if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { + return "", false } for name, selected := range cfg.Remotes { remote := config.Config{Remotes: map[config.Remote]config.RemoteSelect{name: selected}}.GetRemote(name) @@ -150,31 +158,13 @@ func sourceRemoteForDRSURI(cfg *config.Config, primaryRemote config.Remote, drsU continue } if strings.EqualFold(endpoint.Host, u.Host) { - return objectID, name, true + return name, true } } - return objectID, primaryRemote, true + return "", false } -func sourceEndpointForDRSURI(drsURI string) (string, bool) { - u, err := url.Parse(strings.TrimSpace(drsURI)) - if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { - return "", false - } - return "https://" + u.Host, true -} - -func endpointMatchesDRSURI(endpoint string, drsURI string) bool { - u, err := url.Parse(strings.TrimSpace(drsURI)) - if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { - return false - } - endpointURL, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil { - return false - } - return strings.EqualFold(endpointURL.Host, u.Host) -} +var newSourceDRSGetter = newAnonymousSourceDRSGetter func newAnonymousSourceDRSGetter(endpoint string) (drsObjectGetter, error) { raw, err := syclient.New(endpoint) diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index 94a684b6..6f7cf772 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -124,3 +124,109 @@ func TestResolveAddRefObjectUsesSourceAuthorityRemoteCredentials(t *testing.T) { t.Fatalf("expected primary not to be contacted, got %d requests", primaryRequests) } } + +func TestResolveAddRefObjectIgnoresPrimaryEvenWhenPrimaryCanResolve(t *testing.T) { + var primaryRequests int + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + primaryRequests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"primary-object","self_uri":"drs://primary/object-1","size":99}`)) + })) + defer primary.Close() + + var sourceRequests int + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceRequests++ + if r.URL.Path != "/ga4gh/drs/v1/objects/object-1" { + t.Fatalf("unexpected source path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"source-object","self_uri":"drs://` + r.Host + `/object-1","size":42}`)) + })) + defer source.Close() + + cfg := &config.Config{ + DefaultRemote: "primary", + Remotes: map[config.Remote]config.RemoteSelect{ + "primary": {Local: &config.LocalRemote{BaseURL: primary.URL}}, + "source": {Local: &config.LocalRemote{BaseURL: source.URL}}, + }, + } + primaryCtx, err := remoteruntime.New(cfg, "primary", drslog.NewNoOpLogger()) + if err != nil { + t.Fatalf("create primary runtime: %v", err) + } + + obj, err := resolveAddRefObject(context.Background(), cfg, "primary", primaryCtx, "drs://"+strings.TrimPrefix(source.URL, "http://")+"/object-1") + if err != nil { + t.Fatalf("resolveAddRefObject: %v", err) + } + if obj.Id != "source-object" || obj.Size != 42 { + t.Fatalf("expected object from source DRS authority/resolver, got %+v", obj) + } + if sourceRequests != 1 { + t.Fatalf("expected one source request, got %d", sourceRequests) + } + if primaryRequests != 0 { + t.Fatalf("expected primary not to be contacted, got %d requests", primaryRequests) + } +} + +type fakeDRSObjectGetter struct { + gotObjectID *string + obj drsapi.DrsObject +} + +func (g fakeDRSObjectGetter) GetObject(_ context.Context, objectID string) (drsapi.DrsObject, error) { + *g.gotObjectID = objectID + return g.obj, nil +} + +func TestResolveAddRefObjectUsesSourceAuthorityWhenNoRemoteMatches(t *testing.T) { + var primaryRequests int + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + primaryRequests++ + http.Error(w, "primary remote must not resolve source DRS URI", http.StatusTeapot) + })) + defer primary.Close() + + cfg := &config.Config{ + DefaultRemote: "primary", + Remotes: map[config.Remote]config.RemoteSelect{ + "primary": {Local: &config.LocalRemote{BaseURL: primary.URL}}, + }, + } + primaryCtx, err := remoteruntime.New(cfg, "primary", drslog.NewNoOpLogger()) + if err != nil { + t.Fatalf("create primary runtime: %v", err) + } + + var gotEndpoint string + var gotObjectID string + oldGetter := newSourceDRSGetter + newSourceDRSGetter = func(endpoint string) (drsObjectGetter, error) { + gotEndpoint = endpoint + return fakeDRSObjectGetter{ + gotObjectID: &gotObjectID, + obj: drsapi.DrsObject{Id: "source-object", Size: 42}, + }, nil + } + defer func() { newSourceDRSGetter = oldGetter }() + + obj, err := resolveAddRefObject(context.Background(), cfg, "primary", primaryCtx, "drs://source.example.org/object-1") + if err != nil { + t.Fatalf("resolveAddRefObject: %v", err) + } + if obj.Id != "source-object" || obj.Size != 42 { + t.Fatalf("expected object from source DRS authority/resolver, got %+v", obj) + } + if gotEndpoint != "https://source.example.org" { + t.Fatalf("expected source endpoint, got %q", gotEndpoint) + } + if gotObjectID != "object-1" { + t.Fatalf("expected source object ID, got %q", gotObjectID) + } + if primaryRequests != 0 { + t.Fatalf("expected primary not to be contacted, got %d requests", primaryRequests) + } +} diff --git a/docs/commands.md b/docs/commands.md index 3ab73799..6b68646c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -147,6 +147,14 @@ Set the default `git-drs` remote. git drs remote set production ``` +### Remote roles: primary and source authorities + +A repository can have multiple `git-drs` remotes. The configured default remote, `drs.default-remote`, or an explicit command-level remote is the **primary remote** for repository-scoped operations such as push, registration, checksum lookup, and provider inspection. + +A **source DRS authority/resolver** is the DRS service named by a `drs://...` URI or by source metadata recorded for a reference-first object. For `git drs add-ref`, the client resolves the input `drs://...` URI against that source authority/resolver using source credentials. The primary remote does not act as a proxy for other source DRS servers. + +When the source authority is also configured as one of the repository remotes, `git-drs` can use that remote's endpoint and credentials for the source request. When the source authority is not configured as a named remote, the source URI itself remains the retrieval identity; adding the reference still does not create or mutate a primary-remote DRS record. + ## Tracking and Local Inventory ### `git drs track ` diff --git a/docs/pointer-files.md b/docs/pointer-files.md index c6b2c2f5..43e8d0bd 100644 --- a/docs/pointer-files.md +++ b/docs/pointer-files.md @@ -48,7 +48,7 @@ A DRS/provider access URL is usually not stable source identity. It is a tempora `add-ref` creates a reference to an existing DRS object without downloading payload bytes solely to compute a checksum. -1. The client resolves the input DRS URI against the source authority/resolver named by the `drs://...` URI, using credentials appropriate for that source. A primary remote is only used for this metadata fetch when it is explicitly configured as the source resolver/proxy. +1. The client resolves the input DRS URI against the source authority/resolver named by the `drs://...` URI, using credentials appropriate for that source. 2. If the DRS object reports a real SHA256, `git-drs` writes a SHA256 pointer whose OID is that content checksum. 3. If the DRS object does not report a real SHA256, `git-drs` writes a DRS URI pointer whose OID preserves the original `drs://...` source reference. 4. The fetched DRS metadata is stored locally under a deterministic local/cache OID. @@ -130,12 +130,6 @@ sequenceDiagram User->>Source: git drs add-ref drs://source/object data/file Source-->>User: DRS object metadata, size, access methods, optional sha256 note over User,Source: Client resolves drs://source/object against the source authority/resolver using source credentials. - opt Primary remote is explicitly configured as source resolver/proxy - User->>Remote: Resolve drs://source/object through configured proxy - Remote->>Source: Fetch DRS object metadata using configured/delegated source credentials - Source-->>Remote: DRS object metadata - Remote-->>User: Resolved DRS object metadata - end alt Resolved object includes real sha256 User->>Git: Write pointer oid sha256: User->>Local: Store DRS metadata keyed by real sha256 @@ -199,9 +193,9 @@ Expected state immediately after `add-ref` succeeds: | Git/worktree | A pointer file exists at ``. If the resolved DRS object has a real SHA256 checksum, the pointer uses `oid sha256:`; otherwise it uses a git-drs DRS URI pointer that preserves the input DRS URI. | | Local git-drs metadata | The resolved DRS object returned by the selected remote/resolver is stored locally under the local OID used for the pointer/cache lookup. If the DRS object omitted `self_uri`, the input DRS URI is retained as `self_uri`. | | Local payload cache | No payload download is required just to create the reference. The cache may still be empty for this object. | -| Primary remote DRS server | No new record is required merely because `add-ref` ran. The client should resolve the source DRS URI through the source authority/resolver using source credentials for future hydration. The primary remote only needs to resolve that URI when it is explicitly configured as the source resolver/proxy; otherwise, `add-ref` should be treated as creating a local reference to an existing DRS record, not as copying or registering that record into a new project scope. | +| Primary remote DRS server | No new record is required merely because `add-ref` ran. The client should resolve the source DRS URI through the source authority/resolver using source credentials for future hydration. `add-ref` should be treated as creating a local reference to an existing DRS record, not as copying or registering that record into a new project scope. | -If the input DRS URI points at another DRS authority or resolver, the primary remote's durable state after `add-ref` is still unchanged unless the server itself implements and is asked to persist a proxy/copy record. The committed reference remains valid because the pointer/local metadata preserve the source DRS URI. +If the input DRS URI points at another DRS authority or resolver, the primary remote's durable state after `add-ref` is still unchanged. The committed reference remains valid because the pointer/local metadata preserve the source DRS URI and hydration uses that source identity directly. ### After `git drs add-url [path]` From e6bf3e9c9b790df67b49152e81df6ddaf1ce12e7 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 15 Jul 2026 13:10:47 -0700 Subject: [PATCH 12/66] documents Terra creds --- docs/commands.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 6b68646c..e2cba9fe 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -103,6 +103,14 @@ git config drs.remote.anvil.mode read-only git drs ping anvil ``` +Terra credential configuration: + +- `drs.remote..auth` records the credential source that the Terra resolver should use. The currently documented mode is `google-adc`, which means Application Default Credentials from the local Google Cloud environment. +- Do not store Google access tokens, refresh tokens, service-account JSON, or other secrets in repo-local `git config`. The Terra remote stores only the endpoint, credential-source name, and mode. +- Configure ADC outside `git-drs`, for example with `gcloud auth application-default login` for an interactive user credential or by setting `GOOGLE_APPLICATION_CREDENTIALS` to a service-account key path in automation. +- At runtime, `git-drs` loads the Terra remote from `drs.remote..*`, records the Terra endpoint in the remote context, and Terra-aware resolution/download code should obtain Google credentials from the configured source and attach them to outbound Terra/DRSHub/DRS requests as bearer tokens. The credential material is therefore passed through the process environment or ADC provider chain, not through committed repository files. +- `git drs ping` validates the Terra DRS service-info endpoint and prints `auth: none` today because service-info is checked without creating a Syfon bearer/basic credential. Future Terra resolver operations should still honor `drs.remote..auth` when making authenticated object-resolution or access-url requests. + A successful Terra ping includes: ```text From 5fd8c6f57898012445039b1c6153da8d9df8f301 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 15 Jul 2026 13:26:39 -0700 Subject: [PATCH 13/66] adds loadGitConfigOverrides --- internal/config/config.go | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 7c9dfe22..1589308f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "os/exec" "path/filepath" "sort" "strings" @@ -209,6 +210,64 @@ func parseAndAddRemote(cfg *Config, subsectionName string, remoteType string, en cfg.Remotes[remoteName] = rs } +func loadGitConfigOverrides(cfg *Config) error { + cmd := exec.Command("git", "config", "--get-regexp", `^drs\.`) + out, err := cmd.Output() + if err != nil { + // git config exits non-zero when no matching keys exist. In that case, + // the go-git result above is still the complete repository-local config. + return nil + } + + remoteOptions := make(map[Remote]map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, value, ok := strings.Cut(line, " ") + if !ok { + continue + } + value = strings.TrimSpace(value) + if key == "drs.default-remote" { + cfg.DefaultRemote = Remote(value) + continue + } + const prefix = "drs.remote." + if !strings.HasPrefix(key, prefix) { + continue + } + rest := strings.TrimPrefix(key, prefix) + idx := strings.LastIndex(rest, ".") + if idx <= 0 || idx == len(rest)-1 { + continue + } + name := Remote(rest[:idx]) + option := rest[idx+1:] + if remoteOptions[name] == nil { + remoteOptions[name] = make(map[string]string) + } + remoteOptions[name][option] = value + } + + for name, opts := range remoteOptions { + parseAndAddRemote( + cfg, + remoteSubsectionPrefix+string(name), + opts["type"], + opts["endpoint"], + opts["project"], + opts["bucket"], + opts["organization"], + opts["storage_prefix"], + opts["auth"], + opts["mode"], + ) + } + return nil +} + // LoadConfig loads configuration using go-git func LoadConfig() (*Config, error) { repo, err := getRepo() @@ -256,6 +315,10 @@ func LoadConfig() (*Config, error) { } } + if err := loadGitConfigOverrides(cfg); err != nil { + return nil, err + } + return cfg, nil } From b1478d8419c315bc208fdf3daf644a5b7c3b7f6e Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 15 Jul 2026 13:37:32 -0700 Subject: [PATCH 14/66] adds service-info to ping --- cmd/ping/main.go | 34 ++++++++++++------ cmd/ping/main_test.go | 55 ++++++++++++++++++++++++++++-- cmd/ping/terra_integration_test.go | 2 +- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/cmd/ping/main.go b/cmd/ping/main.go index 62a3cb9d..72625975 100644 --- a/cmd/ping/main.go +++ b/cmd/ping/main.go @@ -3,6 +3,7 @@ package ping import ( "context" "fmt" + "io" "log/slog" "net/http" "net/url" @@ -29,11 +30,16 @@ type statusInfo struct { AuthMode string } -var pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) error { +type healthInfo struct { + ServiceInfo string +} + +var pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) (healthInfo, error) { if gc != nil && gc.RemoteType == config.TerraServerType { - return pingTerraServiceInfo(ctx, gc.Endpoint) + serviceInfo, err := pingTerraServiceInfo(ctx, gc.Endpoint) + return healthInfo{ServiceInfo: serviceInfo}, err } - return gc.Client.Health().Ping(ctx) + return healthInfo{}, gc.Client.Health().Ping(ctx) } var pingScopeAccess = func(ctx context.Context, gc *remoteruntime.GitContext) (scopeAccessInfo, error) { @@ -64,10 +70,14 @@ var Cmd = &cobra.Command{ } printStatus(status) - if err := pingHealth(cmd.Context(), gc); err != nil { + health, err := pingHealth(cmd.Context(), gc) + if err != nil { return fmt.Errorf("remote health check failed for %q (%s): %w", status.Remote, status.Endpoint, err) } fmt.Println("health: ok") + if strings.TrimSpace(health.ServiceInfo) != "" { + fmt.Printf("service-info: %s\n", health.ServiceInfo) + } scopeInfo, err := pingScopeAccess(cmd.Context(), gc) if err != nil { @@ -220,27 +230,31 @@ func checkScopeAccess(ctx context.Context, gc *remoteruntime.GitContext) (scopeA return info, nil } -func pingTerraServiceInfo(ctx context.Context, endpoint string) error { +func pingTerraServiceInfo(ctx context.Context, endpoint string) (string, error) { if ctx == nil { ctx = context.Background() } serviceInfoURL, err := terraServiceInfoURL(endpoint) if err != nil { - return err + return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceInfoURL, nil) if err != nil { - return err + return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { - return err + return "", err } defer resp.Body.Close() + serviceInfo, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("terra DRS service-info returned %s", resp.Status) + return "", fmt.Errorf("terra DRS service-info returned %s", resp.Status) + } + if readErr != nil { + return "", readErr } - return nil + return strings.TrimSpace(string(serviceInfo)), nil } func terraServiceInfoURL(endpoint string) (string, error) { diff --git a/cmd/ping/main_test.go b/cmd/ping/main_test.go index 81e290e4..19bd8f8a 100644 --- a/cmd/ping/main_test.go +++ b/cmd/ping/main_test.go @@ -80,6 +80,7 @@ func TestAcceptancePingTerraDRSServer(t *testing.T) { "type: terra", "endpoint: " + terraDRS.URL, "health: ok", + `service-info: {"id":"terra-drs","name":"Terra DRS","type":{"group":"org.ga4gh","artifact":"drs","version":"1.0.0"}}`, } { if !strings.Contains(got, want) { t.Fatalf("expected output to contain %q, got %q", want, got) @@ -152,11 +153,11 @@ func TestPingRunEPrintsStatusAndHealth(t *testing.T) { } oldHealth := pingHealth - pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) error { + pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) (healthInfo, error) { if gc == nil || gc.ProjectId != "end_to_end_test" { t.Fatalf("unexpected git context: %+v", gc) } - return nil + return healthInfo{}, nil } t.Cleanup(func() { pingHealth = oldHealth }) @@ -211,6 +212,54 @@ func TestPingRunEPrintsStatusAndHealth(t *testing.T) { } } +func TestPingRunEPrintsServiceInfo(t *testing.T) { + tmpDir := testutils.SetupTestGitRepo(t) + testutils.CreateTestConfig(t, tmpDir, &config.Config{ + DefaultRemote: config.Remote(config.ORIGIN), + Remotes: map[config.Remote]config.RemoteSelect{ + config.Remote(config.ORIGIN): { + Local: &config.LocalRemote{ + BaseURL: "http://127.0.0.1:8080", + }, + }, + }, + }) + + oldHealth := pingHealth + pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) (healthInfo, error) { + return healthInfo{ServiceInfo: `{"id":"terra-drs","name":"Terra DRS"}`}, nil + } + t.Cleanup(func() { pingHealth = oldHealth }) + + oldScopeAccess := pingScopeAccess + pingScopeAccess = func(ctx context.Context, gc *remoteruntime.GitContext) (scopeAccessInfo, error) { + return scopeAccessInfo{}, nil + } + t.Cleanup(func() { pingScopeAccess = oldScopeAccess }) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = oldStdout }) + + runErr := Cmd.RunE(Cmd, nil) + _ = w.Close() + if runErr != nil { + t.Fatalf("Cmd.RunE returned error: %v", runErr) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + if got, want := buf.String(), `service-info: {"id":"terra-drs","name":"Terra DRS"}`; !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got %q", want, got) + } +} + func TestPingRunEReturnsReadableScopeError(t *testing.T) { tmpDir := testutils.SetupTestGitRepo(t) testutils.CreateTestConfig(t, tmpDir, &config.Config{ @@ -231,7 +280,7 @@ func TestPingRunEReturnsReadableScopeError(t *testing.T) { } oldHealth := pingHealth - pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) error { return nil } + pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) (healthInfo, error) { return healthInfo{}, nil } t.Cleanup(func() { pingHealth = oldHealth }) oldScopeAccess := pingScopeAccess diff --git a/cmd/ping/terra_integration_test.go b/cmd/ping/terra_integration_test.go index df48a81c..6321dded 100644 --- a/cmd/ping/terra_integration_test.go +++ b/cmd/ping/terra_integration_test.go @@ -18,7 +18,7 @@ func TestIntegrationPingTerraDRSServer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) t.Cleanup(cancel) - if err := pingTerraServiceInfo(ctx, endpoint); err != nil { + if _, err := pingTerraServiceInfo(ctx, endpoint); err != nil { t.Fatalf("ping Terra DRS service-info endpoint %q: %v", endpoint, err) } } From e109fe8b94a0aebe52f6c16715de279290192eb4 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 10:44:16 -0700 Subject: [PATCH 15/66] adds anvil poc --- docs/anvil-terra-poc.md | 582 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 582 insertions(+) create mode 100644 docs/anvil-terra-poc.md diff --git a/docs/anvil-terra-poc.md b/docs/anvil-terra-poc.md new file mode 100644 index 00000000..2de8e546 --- /dev/null +++ b/docs/anvil-terra-poc.md @@ -0,0 +1,582 @@ +# AnVIL/Terra DRS Reference Proof of Concept + +## Purpose + +This document defines a focused proof of concept (POC) for using `git-drs` with the AnVIL/Terra DRS ecosystem. The POC is a **reference-only workflow**: data already exists behind AnVIL DRS records, and Git stores portable references to that data rather than uploading or copying payload bytes. + +The intended end-to-end flow is: + +1. An authorized user adds several AnVIL DRS references to a Git repository. +2. The user commits the generated pointer files and pushes the Git repository with ordinary `git push`. +3. A second authorized user clones the repository. +4. The second user authenticates with their own Terra/AnVIL identity and runs `git drs pull` to hydrate the referenced files. + +## Scope + +The POC includes: + +- a read-only Terra remote; +- Google Application Default Credentials (ADC) authentication; +- resolution of AnVIL DRS URIs to authorized access URLs; +- single-reference and manifest-driven batch `add-ref` workflows; +- portable pointer files containing canonical DRS URIs; +- non-secret, repository-level remote configuration; +- selective and complete hydration after a fresh clone; +- size and checksum verification where metadata is available. + +The POC does not include: + +- uploading payload bytes to AnVIL; +- creating, editing, or deleting AnVIL DRS records; +- copying AnVIL records into Syfon or Gen3; +- destructive remote garbage collection; +- automatic Terra workspace-table synchronization; +- support for every DRS provider or Google authentication mode. + +## User Stories + +### Data reference author + +As an authorized AnVIL user, I want to add one or more existing DRS objects to a Git repository so that I can publish a reviewable, versioned dataset layout without downloading or committing the payload bytes. + +Acceptance criteria: + +- I can add one reference by DRS URI and destination path. +- I can add many references from a manifest. +- The command validates every DRS URI using my identity. +- Generated pointers contain no payload data, access token, signed URL, or other secret. +- The references can be committed and published with ordinary Git commands. + +### Data consumer + +As a second authorized AnVIL user, I want to clone the Git repository and hydrate its referenced files using my own identity so that I can reproduce the repository's data layout without receiving the author's credentials or clone-local state. + +Acceptance criteria: + +- A fresh clone contains portable pointer files. +- The repository supplies non-secret Terra remote configuration. +- I authenticate independently using Google ADC. +- `git drs pull` hydrates all authorized references. +- `git drs pull -I ` hydrates only selected references. +- Downloaded content is checked against its expected size and checksum when available. + +### Unauthorized repository reader + +As a person who can read the Git repository but lacks AnVIL data authorization, I may inspect paths and DRS references, but I must not be able to download controlled-access payloads. + +Acceptance criteria: + +- Cloning Git does not grant data access. +- Pull reports a clear authorization error. +- No committed file or log exposes another user's credential or signed download URL. + +### Repository maintainer + +As a repository maintainer, I want deterministic pointers, actionable diagnostics, and repeatable tests so that reference changes are reviewable and failures can be distinguished from authorization problems. + +## User Experience + +### Prerequisites + +Both users need: + +- Git and `git-drs`; +- access to the Git repository; +- a Google identity authorized for the referenced AnVIL data; +- working Google ADC, initially established with the supported Google authentication tooling. + +The repository contains a non-secret `.git-drs/config.yaml` file: + +```yaml +version: 1 +default_remote: anvil +remotes: + anvil: + type: terra + endpoint: https://data.terra.bio + auth: google-adc + mode: read-only +``` + +Local Git configuration may override repository configuration, but secrets are never permitted in `.git-drs/config.yaml`. + +### First user: add and publish references + +For individual objects: + +```bash +git clone +cd + +gcloud auth application-default login + +git drs add-ref --remote anvil \ + drs:/// data/sample-1.cram +git drs add-ref --remote anvil \ + drs:/// data/sample-2.cram + +git add .git-drs/config.yaml .gitattributes data/ +git commit -m "Add AnVIL data references" +git push +``` + +For multiple objects, the preferred POC workflow is a manifest: + +```bash +git drs add-ref --remote anvil --manifest references.tsv +git add .git-drs/config.yaml .gitattributes data/ +git commit -m "Add AnVIL data references" +git push +``` + +The minimum manifest schema is: + +```text +drs_uri\tpath +drs:///\tdata/sample-1.cram +drs:///\tdata/sample-2.cram +``` + +Optional `size` and `sha256` columns can avoid redundant metadata work, but the command must validate supplied values against authoritative DRS metadata before writing pointers. + +### Second user: clone and hydrate + +```bash +gcloud auth application-default login + +git clone +cd +git drs pull +``` + +Selective hydration remains available: + +```bash +git drs pull -I "data/*.cram" +``` + +The second user does not need the first user's `.git/drs` directory, local Git configuration, cached objects, access tokens, or signed URLs. + +## Actual Blockers + +### 1. Terra runtime does not construct an authenticated data resolver + +Terra configuration and runtime selection exist, but the Terra runtime currently creates a context without the Syfon client used by the existing `add-ref` and pull implementations. Terra can therefore be configured and health-checked, but its object-resolution and download path is incomplete. + +The POC needs a real AnVIL resolver that authenticates with Google ADC and can retrieve object metadata and authorized access information. + +### 2. Data operations are coupled to the Syfon client + +Current reference and download paths call `GitContext.Client.DRS()` directly. This assumes every provider can be represented by the Syfon client and fails for the current Terra context. Provider-neutral resolution must replace direct Syfon coupling in `add-ref` and pull. + +### 3. `--remote-type` is exposed but does not select behavior + +`add-ref` exposes `--remote-type`, but resolver behavior should be selected from the configured remote. The normal interface should be `--remote anvil`; the command loads that remote, observes `type: terra`, and uses the AnVIL resolver automatically. + +If `--remote-type` remains temporarily, it must be hidden or deprecated and must be rejected when it conflicts with the selected remote. + +### 4. SHA256-bearing references can lose their DRS identity + +Current `add-ref` behavior writes a Git LFS-style SHA256 pointer when DRS metadata includes SHA256, while it writes a DRS URI pointer only when SHA256 is unavailable. It stores supplementary metadata under `.git/drs`, which is clone-local and is not transferred by Git. + +As a result, a fresh clone may receive a SHA256 and size but not the canonical AnVIL DRS URI needed to resolve the original record. The POC cannot depend on checksum search or the first user's `.git/drs` metadata. + +### 5. Pointer identity, cache identity, and content identity are conflated + +The current implementation often treats a pointer OID as both a cache key and content SHA256. AnVIL references require three explicit concepts: + +- canonical remote identity: the normalized DRS URI; +- local cache identity: a deterministic, filesystem-safe hash derived from the DRS URI; +- content identity: SHA256 or another durable checksum supplied by DRS metadata. + +### 6. Current DRS URI resolution is not AnVIL-aware + +Deriving `https://` and constructing an anonymous generic client is insufficient for authenticated AnVIL resolution. The configured Terra resolver must own authority routing, authentication, object lookup, and access URL retrieval. + +### 7. Repository-level remote configuration is missing + +Git-local remote configuration is not cloned. Without committed public configuration, every consumer must manually reconstruct the same endpoint, authentication mode, and resolver type. The POC needs a tracked, non-secret configuration format with safe local overrides. + +### 8. There is no complete two-user AnVIL acceptance test + +Existing focused tests cover pieces of Terra configuration, pointer parsing, and ping behavior. A functional POC requires a clean-clone test using independent user state and real or contract-faithful AnVIL resolution. + +## Required Prototype Decision: Commit the Canonical DRS URI + +Every Terra/AnVIL reference **must commit the canonical DRS URI in the pointer file**, including when the DRS record supplies a SHA256 checksum. + +This is required because: + +- `.git/drs` metadata is local and is not pushed or cloned; +- a checksum is content metadata, not necessarily a resolvable AnVIL record identifier; +- the second user must be able to resolve the same record from Git state alone; +- signed access URLs are temporary and must never be committed. + +The initial pointer format is: + +```text +version https://calypr.github.io/spec/v1 +oid drs:/// +size +sha256 +``` + +If the pointer parser cannot initially support the optional checksum line, the minimum viable pointer is: + +```text +version https://calypr.github.io/spec/v1 +oid drs:/// +size +``` + +Pull may retrieve the checksum from DRS metadata before download. Under no circumstances may the presence of SHA256 cause Terra `add-ref` to omit the DRS URI from committed state. + +For local storage, derive a cache key without replacing canonical identity: + +```text +cache_oid = sha256("git-drs-anvil-ref:v1\n" + normalized_drs_uri) +``` + +## Implementation Plan + +### Phase 1: Define a provider-neutral resolver + +Introduce a provider-neutral interface used by both `add-ref` and pull: + +```go +type Resolver interface { + GetObject(ctx context.Context, drsURI string) (*ResolvedObject, error) + GetAccess(ctx context.Context, drsURI, accessID string) (*ResolvedAccess, error) +} + +type ResolvedObject struct { + DRSURI string + ID string + Name string + Size int64 + Checksums map[string]string + AccessMethods []AccessMethod +} + +type ResolvedAccess struct { + URL string + Headers map[string]string + ExpiresAt *time.Time +} +``` + +Implement a resolver factory selected by configured remote type: + +- `SyfonResolver` for existing Gen3/local behavior; +- `AnVILResolver` for Terra/AnVIL behavior. + +Resolver results must not be serialized to tracked files when they contain signed URLs, credentials, or authorization headers. + +### Phase 2: Implement AnVIL authentication + +Support only `auth: google-adc` for the POC. + +The implementation must: + +1. Obtain credentials from the current user's ADC environment. +2. Request the scopes required by the supported AnVIL resolver. +3. attach bearer tokens only to trusted resolver endpoints; +4. refresh tokens automatically; +5. redact authorization headers and signed URLs from logs and errors; +6. return distinct errors for absent credentials, expired credentials, and denied authorization. + +Tokens, refresh credentials, signed URLs, and request headers must never be written to Git configuration or repository files. + +### Phase 3: Implement AnVIL DRS URI resolution + +The AnVIL resolver must: + +1. Parse and validate `drs:///`. +2. Normalize and preserve the original URI as canonical identity. +3. Route the reference through the configured AnVIL/Terra resolver contract. +4. Retrieve object size, durable checksums, and supported access methods. +5. Resolve an authorized access URL only when a download begins. +6. distinguish not-found, unauthorized, resolver-unavailable, and unsupported-access-method failures. + +Authority routing must be implemented inside `AnVILResolver`; it must not assume every URI authority is itself an HTTPS GA4GH DRS endpoint. Exact production endpoints, OAuth scopes, resolver requests, and response contracts must be validated against current authoritative AnVIL/Terra documentation before implementation is considered complete. + +### Phase 4: Make Terra pointer files self-contained + +Change Terra `add-ref` to always write a DRS pointer, regardless of checksum availability. + +The command must: + +- preserve the normalized DRS URI; +- store expected size; +- store SHA256 when available; +- avoid writing signed access information; +- write pointers deterministically; +- reject destination paths outside the repository; +- avoid depending on clone-local `.git/drs` metadata for later resolution. + +Update parsing, inventory, clean/smudge handling, and status output to preserve the distinction between a DRS URI and a SHA256-shaped OID. + +### Phase 5: Separate remote, cache, and content identity + +Introduce an explicit identity value: + +```go +type ObjectIdentity struct { + DRSURI string + CacheOID string + ContentSHA256 string +} +``` + +Use: + +- `DRSURI` for object and access resolution; +- `CacheOID` for local cache fanout paths; +- `ContentSHA256` for integrity validation when available. + +Normalize DRS URIs before deriving `CacheOID`, version the derivation scheme, and add golden tests so future normalization changes do not silently orphan cached content. + +### Phase 6: Hydrate through the AnVIL resolver + +For each DRS pointer, pull must: + +1. Parse its canonical DRS URI, expected size, and optional checksum. +2. Derive its local cache OID. +3. reuse an existing cache entry only after validating it; +4. resolve current object metadata with the selected AnVIL remote; +5. request a fresh authorized access URL; +6. download to a temporary file with cancellation and bounded retry; +7. validate size and SHA256 when available; +8. atomically promote verified content into the cache; +9. hydrate the destination path; +10. leave the pointer and cache uncorrupted on failure. + +Expired signed URLs must be handled by resolving a new URL rather than persisting or reusing stale access information. + +### Phase 7: Add manifest-driven batch `add-ref` + +Add: + +```bash +git drs add-ref --remote anvil --manifest references.tsv +``` + +The implementation must: + +1. Support required `drs_uri` and `path` columns. +2. Optionally accept `size` and `sha256` as asserted metadata. +3. Validate the entire manifest before modifying the worktree. +4. Reject malformed URIs, absolute paths, repository escapes, duplicate paths, and conflicting entries. +5. Resolve metadata with a small bounded worker pool. +6. compare asserted size/checksum values with authoritative DRS metadata; +7. report all validation failures together where practical; +8. write deterministic pointers after successful validation; +9. support `--dry-run`; +10. print a deterministic result summary. + +The batch operation is a wrapper over the same single-reference primitive, not an independent resolution system. + +### Phase 8: Commit non-secret repository configuration + +Adopt Option B: commit `.git-drs/config.yaml` so a clone carries the public information required to select the correct resolver. + +Initial schema: + +```yaml +version: 1 +default_remote: anvil +remotes: + anvil: + type: terra + endpoint: https://data.terra.bio + auth: google-adc + mode: read-only +``` + +Configuration rules: + +- only an allowlisted schema may be loaded from tracked configuration; +- credentials, tokens, arbitrary headers, signed URLs, and local paths are forbidden; +- local Git configuration may override public endpoint or remote selection; +- secret-bearing configuration remains local and takes precedence; +- commands must identify whether an effective setting came from repository or local configuration; +- invalid or secret-like tracked fields must fail closed with an actionable error. + +With this configuration committed, the second user's desired flow is reduced to authentication, clone, and pull. + +## Git Push Semantics for This Prototype + +AnVIL data already exists and the configured Terra remote is read-only. Publishing references therefore uses ordinary Git: + +```bash +git add .git-drs/config.yaml .gitattributes data/ +git commit -m "Add AnVIL data references" +git push +``` + +`git push` publishes only: + +- pointer files; +- repository paths and Git history; +- `.gitattributes` rules; +- non-secret `.git-drs/config.yaml`; +- an optional reference manifest when the repository chooses to track it. + +It does not: + +- upload payload bytes; +- create or update AnVIL DRS records; +- copy records into another DRS service; +- transfer user credentials; +- persist signed download URLs. + +`git drs push` against a read-only Terra remote must fail before performing work with an actionable message such as: + +```text +remote "anvil" is read-only; publish AnVIL references with ordinary git push +``` + +Plain `git push` must not attempt to contact AnVIL. Data authorization is evaluated later, independently for each user, when `git drs pull` resolves and downloads the committed references. + +## Prototype Acceptance Tests + +### 1. Single-reference two-user flow + +1. User A authenticates with ADC. +2. User A adds a stable AnVIL DRS reference. +3. The generated file is a pointer containing the canonical DRS URI. +4. User A commits and pushes with ordinary Git. +5. User B clones into a clean environment. +6. The cloned file remains a pointer. +7. User B authenticates independently. +8. User B runs `git drs pull -I `. +9. The file is hydrated and matches expected size and checksum. + +### 2. Multiple-reference manifest flow + +Import at least ten references, including: + +- small and multipart-sized objects; +- nested destination paths; +- records with SHA256; +- a record without SHA256, if the target test data provides one. + +Verify deterministic pointers, full pull, selective pull, and an accurate summary. + +### 3. Fresh-clone portability + +Before User B clones, ensure no User A state is copied, including: + +- `.git/drs`; +- `.git/lfs/objects`; +- local Git configuration; +- environment tokens; +- ADC files. + +Successful User B hydration proves that committed pointers and repository configuration are sufficient. + +### 4. Authorization isolation + +A user who can clone Git but cannot access the AnVIL objects must receive a clear authorization error. Confirm that no token, authorization header, or signed URL appears in Git history, pointer content, standard output, standard error, or logs. + +### 5. Missing and malformed references + +Verify actionable, distinct failures for: + +- malformed DRS URI; +- unknown record; +- unsupported authority; +- record without a supported access method; +- duplicate or escaping destination path. + +Batch validation should avoid partial worktree modification unless partial behavior is explicitly requested. + +### 6. Expired access URL + +Simulate or wait for access URL expiry. Pull must resolve a fresh URL and continue without relying on stored access information. + +### 7. Integrity failure + +Inject incorrect bytes or metadata. Pull must fail verification, delete temporary/corrupt cache content, and leave the worktree pointer intact. + +### 8. Interrupted download and retry + +Interrupt a download and retry it. The retry must either resume safely or restart cleanly, never treating partial bytes as a valid cache entry. + +### 9. Idempotency and cache reuse + +After successful hydration, repeated `git drs pull` must not redownload verified content. Two paths referencing the same canonical URI should reuse the same cache entry. + +### 10. Read-only push behavior + +Verify that: + +- ordinary `git push` succeeds without contacting AnVIL; +- `git drs push` against the Terra remote fails clearly and performs no registration, upload, or deletion. + +### 11. Repository configuration safety + +Verify that tracked configuration: + +- loads after a fresh clone; +- can be safely overridden locally; +- rejects secret-bearing or unknown sensitive fields; +- never shadows explicit local credentials with repository-controlled secrets. + +## Backlog in Priority Order + +### P0: required for the two-user POC + +1. Introduce the provider-neutral resolver abstraction. +2. Confirm the authoritative AnVIL resolver endpoint, request contract, OAuth scopes, and supported access methods. +3. Implement Google ADC authentication and token refresh. +4. Implement AnVIL object metadata and access resolution. +5. Make Terra `add-ref` always commit the canonical DRS URI. +6. Add explicit DRS URI, cache OID, and content checksum identities. +7. Refactor pull to use `AnVILResolver` rather than a Syfon client. +8. Validate size and SHA256 and atomically manage cache content. +9. Implement and safely load `.git-drs/config.yaml`. +10. Reject `git drs push` for read-only Terra remotes. +11. Add a real or contract-faithful two-user clone-and-pull acceptance test. + +### P1: required for a usable prototype + +1. Add manifest-driven batch `add-ref` and `--dry-run`. +2. Add bounded parallel metadata resolution and download. +3. Add safe retry, cancellation, and expired-URL re-resolution. +4. Add `git drs doctor` checks for ADC, configuration, endpoint health, resolution, authorization, and pointer validity. +5. Improve errors for absent credentials, denied access, missing records, invalid manifests, and integrity failures. +6. Add CI coverage for clean-clone portability, authorization isolation, and repository configuration safety. +7. Publish versioned binaries and a compatibility matrix for the tested AnVIL contract. + +### P2: explicitly deferred + +1. Uploading payloads to AnVIL. +2. Creating, editing, or deleting AnVIL DRS records. +3. Copying records to Syfon or Gen3. +4. Destructive delete and remote garbage collection. +5. Automatic Terra workspace-table synchronization. +6. Additional Google authentication modes. +7. Generalized support for every DRS authority or provider. +8. A broad multi-provider pointer-format redesign beyond what portable AnVIL references require. + +## Definition of Done + +The AnVIL/Terra reference POC is functional when: + +- User A can add at least ten AnVIL DRS references in one manifest command. +- Each generated pointer commits the normalized canonical DRS URI. +- Pointers also preserve expected size and SHA256 when available. +- The Git repository contains no payload bytes, credentials, authorization headers, or signed URLs. +- The repository commits a validated, non-secret `.git-drs/config.yaml` selecting the read-only AnVIL remote. +- Ordinary `git push` publishes the references without contacting AnVIL. +- `git drs push` refuses to operate on the read-only Terra remote. +- User B can clone without receiving any User A clone-local metadata, cache, or credentials. +- User B can authenticate with their own Google ADC identity and run `git drs pull` successfully. +- Full and selective hydration both work. +- An unauthorized user can clone pointers but cannot hydrate controlled-access data. +- Every download is size-verified and SHA256-verified when a checksum is available. +- Interrupted, expired-URL, and integrity-failure paths do not poison the cache or overwrite pointers with invalid content. +- Repeated pulls are idempotent and reuse verified cache entries. +- The complete two-user flow runs as a documented acceptance test in a clean environment. + From 0392d64ca8edd2c8fb9860f440d5b22b399262f9 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 11:56:25 -0700 Subject: [PATCH 16/66] Implement AnVIL/Terra reference workflow --- cmd/addref/add-ref.go | 220 +++++++++++++++- cmd/pull/main.go | 81 +++++- cmd/push/main.go | 3 + docs/anvil.md | 99 ++++++++ internal/config/config.go | 73 +++++- internal/config/repository_config_test.go | 51 ++++ internal/gitrepo/paths.go | 1 + internal/lfs/inventory.go | 17 ++ internal/lfs/object_path.go | 2 +- internal/lfs/terra_pointer_acceptance_test.go | 11 + internal/resolver/resolver.go | 237 ++++++++++++++++++ internal/resolver/resolver_test.go | 51 ++++ 12 files changed, 831 insertions(+), 15 deletions(-) create mode 100644 docs/anvil.md create mode 100644 internal/config/repository_config_test.go create mode 100644 internal/resolver/resolver.go create mode 100644 internal/resolver/resolver_test.go diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 0a1e0a40..09545e9d 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -3,11 +3,14 @@ package addref import ( "context" "crypto/sha256" + "encoding/csv" "encoding/hex" "fmt" "net/url" "os" "path/filepath" + "sort" + "strconv" "strings" "github.com/calypr/git-drs/internal/config" @@ -16,6 +19,7 @@ import ( "github.com/calypr/git-drs/internal/gitrepo" "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/remoteruntime" + "github.com/calypr/git-drs/internal/resolver" drsapi "github.com/calypr/syfon/apigen/client/drs" syclient "github.com/calypr/syfon/client" "github.com/calypr/syfon/client/hash" @@ -24,12 +28,25 @@ import ( var remote string var remoteType string +var manifestPath string +var dryRun bool var Cmd = &cobra.Command{ - Use: "add-ref ", + Use: "add-ref | --manifest ", Short: "Add a reference to an existing DRS object via URI", Long: "Add a reference to an existing DRS object via URI. Requires that the sha256 of the file is already in the cache", - Args: cobra.ExactArgs(2), + Args: func(cmd *cobra.Command, args []string) error { + if manifestPath != "" && len(args) == 0 { + return nil + } + if manifestPath != "" { + return fmt.Errorf("positional arguments cannot be combined with --manifest") + } + return cobra.ExactArgs(2)(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { + if manifestPath != "" { + return runManifest(cmd, manifestPath) + } drsUri := args[0] dstPath := args[1] @@ -47,6 +64,25 @@ var Cmd = &cobra.Command{ logger.Error(fmt.Sprintf("Error getting remote: %v", err)) return err } + selected := cfg.GetRemote(remoteName) + if selected == nil { + return fmt.Errorf("remote %q is not configured", remoteName) + } + if remoteType != "" { + actual := "gen3" + if _, ok := cfg.Remotes[remoteName]; ok && cfg.Remotes[remoteName].Terra != nil { + actual = "terra" + } else if cfg.Remotes[remoteName].Local != nil { + actual = "local" + } + if remoteType != actual { + return fmt.Errorf("--remote-type %q conflicts with configured remote %q type %q", remoteType, remoteName, actual) + } + } + dstPath, err = safeDestination(dstPath) + if err != nil { + return err + } client, err := remoteruntime.New(cfg, remoteName, logger) if err != nil { @@ -57,6 +93,10 @@ var Cmd = &cobra.Command{ if err != nil { return err } + if dryRun { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%d\n1 reference(s) validated\n", drsUri, args[1], obj.Size) + return err + } dirPath := filepath.Dir(dstPath) _, err = os.Stat(dirPath) if os.IsNotExist(err) { @@ -65,7 +105,11 @@ var Cmd = &cobra.Command{ } oid := addRefLocalOID(drsUri, remoteName, &obj) - if hasContentSHA256(&obj) { + if client.RemoteType == config.TerraServerType { + if err := lfs.CreateDRSPointer(&obj, dstPath, drsUri); err != nil { + return err + } + } else if hasContentSHA256(&obj) { if err := lfs.CreateLfsPointerWithOID(&obj, dstPath, oid); err != nil { return err } @@ -82,9 +126,156 @@ var Cmd = &cobra.Command{ }, } +func safeDestination(dst string) (string, error) { + if filepath.IsAbs(dst) { + return "", fmt.Errorf("destination path must be relative to the repository: %s", dst) + } + clean := filepath.Clean(dst) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("destination path escapes the repository: %s", dst) + } + root, err := gitrepo.GitTopLevel() + if err != nil { + return "", err + } + return filepath.Join(root, clean), nil +} + func init() { Cmd.Flags().StringVarP(&remote, "remote", "r", "", "target remote DRS server (default: default_remote)") Cmd.Flags().StringVar(&remoteType, "remote-type", "", "resolver remote type for DRS references (for example: terra)") + Cmd.Flags().StringVar(&manifestPath, "manifest", "", "add references from a tab-separated manifest") + Cmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate and report references without writing pointers") + _ = Cmd.Flags().MarkHidden("remote-type") + _ = Cmd.Flags().MarkDeprecated("remote-type", "remote behavior is selected by --remote configuration") +} + +type manifestEntry struct { + uri, path, sha256 string + size *int64 + object drsapi.DrsObject + destination string +} + +func runManifest(cmd *cobra.Command, filename string) error { + f, err := os.Open(filename) + if err != nil { + return fmt.Errorf("open manifest: %w", err) + } + defer f.Close() + r := csv.NewReader(f) + r.Comma = '\t' + r.FieldsPerRecord = -1 + r.TrimLeadingSpace = true + rows, err := r.ReadAll() + if err != nil { + return fmt.Errorf("parse manifest: %w", err) + } + if len(rows) < 2 { + return fmt.Errorf("manifest must contain a header and at least one reference") + } + columns := map[string]int{} + for i, name := range rows[0] { + columns[strings.ToLower(strings.TrimSpace(name))] = i + } + for _, required := range []string{"drs_uri", "path"} { + if _, ok := columns[required]; !ok { + return fmt.Errorf("manifest is missing required %q column", required) + } + } + value := func(row []string, name string) string { + i, ok := columns[name] + if !ok || i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) + } + entries := make([]manifestEntry, 0, len(rows)-1) + seen := map[string]int{} + var problems []string + for i, row := range rows[1:] { + e := manifestEntry{uri: value(row, "drs_uri"), path: value(row, "path"), sha256: strings.ToLower(strings.TrimPrefix(value(row, "sha256"), "sha256:"))} + if _, _, err := resolver.NormalizeDRSURI(e.uri); err != nil { + problems = append(problems, fmt.Sprintf("row %d: %v", i+2, err)) + } + clean := filepath.Clean(e.path) + if prior, ok := seen[clean]; ok { + problems = append(problems, fmt.Sprintf("row %d: duplicate path %q (also row %d)", i+2, e.path, prior)) + } else { + seen[clean] = i + 2 + } + e.destination, err = safeDestination(e.path) + if err != nil { + problems = append(problems, fmt.Sprintf("row %d: %v", i+2, err)) + } + if raw := value(row, "size"); raw != "" { + n, parseErr := strconv.ParseInt(raw, 10, 64) + if parseErr != nil || n < 0 { + problems = append(problems, fmt.Sprintf("row %d: invalid size %q", i+2, raw)) + } else { + e.size = &n + } + } + if e.sha256 != "" && (len(e.sha256) != 64 || strings.Trim(e.sha256, "0123456789abcdef") != "") { + problems = append(problems, fmt.Sprintf("row %d: invalid sha256", i+2)) + } + entries = append(entries, e) + } + if len(problems) > 0 { + return fmt.Errorf("manifest validation failed:\n- %s", strings.Join(problems, "\n- ")) + } + cfg, err := config.LoadConfig() + if err != nil { + return err + } + remoteName, err := cfg.GetRemoteOrDefault(remote) + if err != nil { + return err + } + runtime, err := remoteruntime.New(cfg, remoteName, drslog.GetLogger()) + if err != nil { + return err + } + for i := range entries { + obj, resolveErr := resolveAddRefObject(cmd.Context(), cfg, remoteName, runtime, entries[i].uri) + if resolveErr != nil { + problems = append(problems, fmt.Sprintf("row %d: %v", i+2, resolveErr)) + continue + } + entries[i].object = obj + if entries[i].size != nil && *entries[i].size != obj.Size { + problems = append(problems, fmt.Sprintf("row %d: asserted size %d does not match authoritative size %d", i+2, *entries[i].size, obj.Size)) + } + authSHA := drsobject.NormalizeChecksum(hash.ConvertDrsChecksumsToHashInfo(obj.Checksums).SHA256) + if entries[i].sha256 != "" && !strings.EqualFold(entries[i].sha256, authSHA) { + problems = append(problems, fmt.Sprintf("row %d: asserted sha256 does not match authoritative checksum", i+2)) + } + } + if len(problems) > 0 { + return fmt.Errorf("manifest validation failed:\n- %s", strings.Join(problems, "\n- ")) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path }) + for _, e := range entries { + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%d\n", e.uri, e.path, e.object.Size) + continue + } + if err := os.MkdirAll(filepath.Dir(e.destination), 0o755); err != nil { + return err + } + if runtime.RemoteType == config.TerraServerType { + err = lfs.CreateDRSPointer(&e.object, e.destination, e.uri) + } else if hasContentSHA256(&e.object) { + err = lfs.CreateLfsPointerWithOID(&e.object, e.destination, addRefLocalOID(e.uri, remoteName, &e.object)) + } else { + err = lfs.CreateDRSPointer(&e.object, e.destination, e.uri) + } + if err != nil { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "%d reference(s) %s\n", len(entries), map[bool]string{true: "validated", false: "added"}[dryRun]) + return nil } func hasContentSHA256(obj *drsapi.DrsObject) bool { @@ -110,6 +301,25 @@ type drsObjectGetter interface { } func resolveAddRefObject(ctx context.Context, cfg *config.Config, primaryRemote config.Remote, primary *remoteruntime.GitContext, drsURI string) (drsapi.DrsObject, error) { + if primary != nil && primary.RemoteType == config.TerraServerType { + anvil, err := newAnVILResolver(ctx, primary.Endpoint) + if err != nil { + return drsapi.DrsObject{}, err + } + resolved, err := anvil.GetObject(ctx, drsURI) + if err != nil { + return drsapi.DrsObject{}, err + } + checksums := make([]drsapi.Checksum, 0, len(resolved.Checksums)) + for _, checksum := range resolved.Checksums { + checksums = append(checksums, drsapi.Checksum{Type: checksum.Type, Checksum: checksum.Checksum}) + } + var name *string + if resolved.Name != "" { + name = &resolved.Name + } + return drsapi.DrsObject{Id: resolved.ID, Name: name, Size: resolved.Size, SelfUri: resolved.DRSURI, Checksums: checksums}, nil + } objectID, sourceEndpoint, ok := parseDRSURIForSource(drsURI) if !ok { return primary.Client.DRS().GetObject(ctx, drsURI) @@ -131,6 +341,10 @@ func resolveAddRefObject(ctx context.Context, cfg *config.Config, primaryRemote return getter.GetObject(ctx, objectID) } +var newAnVILResolver = func(ctx context.Context, endpoint string) (resolver.Resolver, error) { + return resolver.NewAnVIL(ctx, endpoint) +} + func parseDRSURIForSource(drsURI string) (objectID string, endpoint string, ok bool) { u, err := url.Parse(strings.TrimSpace(drsURI)) if err != nil || !strings.EqualFold(u.Scheme, "drs") || strings.TrimSpace(u.Host) == "" { diff --git a/cmd/pull/main.go b/cmd/pull/main.go index 660c3f97..27efa844 100644 --- a/cmd/pull/main.go +++ b/cmd/pull/main.go @@ -21,6 +21,7 @@ import ( "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/lookup" "github.com/calypr/git-drs/internal/remoteruntime" + "github.com/calypr/git-drs/internal/resolver" internaltransfer "github.com/calypr/git-drs/internal/transfer" drsapi "github.com/calypr/syfon/apigen/client/drs" sycommon "github.com/calypr/syfon/client/common" @@ -93,6 +94,13 @@ var Cmd = &cobra.Command{ logg.Error(fmt.Sprintf("error creating DRS client: %s", err)) return err } + var anvil resolver.Resolver + if drsCtx.RemoteType == config.TerraServerType { + anvil, err = resolver.NewAnVIL(cmd.Context(), drsCtx.Endpoint) + if err != nil { + return err + } + } progress := internaltransfer.NewPullProgressRenderer(os.Stderr) progress.OnPlan(toPullFiles(pointers)) @@ -110,7 +118,7 @@ var Cmd = &cobra.Command{ if err != nil { return fmt.Errorf("failed to resolve LFS object path for %s: %w", f.Oid, err) } - state, err := inspectCachedObject(cachePath, f.Oid, f.Size) + state, err := inspectCachedPointer(cachePath, f) if err == nil && state.complete { continue } else if err != nil { @@ -126,6 +134,9 @@ var Cmd = &cobra.Command{ if len(missingOIDs) > 0 { prefetched := make(map[string]drsapi.DrsObject, len(missingOIDs)) for _, oid := range missingOIDs { + if isDRSPointerOID(oid) { + continue + } recs, err := lookup.ObjectsByHashForScope(ctx, drsCtx, oid) if err != nil || len(recs) == 0 { continue @@ -156,7 +167,7 @@ var Cmd = &cobra.Command{ if err != nil { return fmt.Errorf("failed to resolve LFS object path for %s: %w", f.Oid, err) } - state, err := inspectCachedObject(dstPath, f.Oid, f.Size) + state, err := inspectCachedPointer(dstPath, f) if err == nil && state.complete { continue } else if err != nil { @@ -184,15 +195,21 @@ var Cmd = &cobra.Command{ } } if isDRSPointerOID(f.Oid) { - if err := internaltransfer.DownloadDRSURIToCachePath(downloadCtx, drsCtx, f.Oid, dstPath); err != nil { + var downloadErr error + if anvil != nil { + downloadErr = resolver.DownloadToCache(downloadCtx, anvil, normalizeDRSPointerOID(f.Oid), dstPath) + } else { + downloadErr = internaltransfer.DownloadDRSURIToCachePath(downloadCtx, drsCtx, f.Oid, dstPath) + } + if downloadErr != nil { debugCtx := buildPullDownloadDebugContext(ctx, drsCtx, f.Oid) - return fmt.Errorf("failed to download DRS URI %s to %s: %w\npull-debug: %s", f.Oid, dstPath, err, debugCtx) + return fmt.Errorf("failed to download DRS URI %s to %s: %w\npull-debug: %s", f.Oid, dstPath, downloadErr, debugCtx) } } else if err := internaltransfer.DownloadToCachePath(downloadCtx, drsCtx, f.Oid, dstPath); err != nil { debugCtx := buildPullDownloadDebugContext(ctx, drsCtx, f.Oid) return fmt.Errorf("failed to download oid %s to %s: %w\npull-debug: %s", f.Oid, dstPath, err, debugCtx) } - if err := verifyObjectAtPath(dstPath, f.Oid, f.Size); err != nil { + if err := verifyPointerAtPath(dstPath, f); err != nil { _ = os.Remove(dstPath) return fmt.Errorf("downloaded invalid cached object for oid %s: %w", f.Oid, err) } @@ -212,10 +229,18 @@ var Cmd = &cobra.Command{ }, } +func normalizeDRSPointerOID(oid string) string { + if strings.HasPrefix(oid, "//") { + return "drs:" + oid + } + return oid +} + type pointerFile struct { - Name string - Oid string - Size int64 + Name string + Oid string + Size int64 + SHA256 string } func collectPointerFiles(inventory map[string]lfs.LfsFileInfo, patterns []string) []pointerFile { @@ -231,11 +256,41 @@ func collectPointerFiles(inventory map[string]lfs.LfsFileInfo, patterns []string files := make([]pointerFile, 0, len(keys)) for _, path := range keys { info := inventory[path] - files = append(files, pointerFile{Name: path, Oid: info.Oid, Size: info.Size}) + files = append(files, pointerFile{Name: path, Oid: info.Oid, Size: info.Size, SHA256: info.SHA256}) } return files } +func inspectCachedPointer(path string, file pointerFile) (cachedObjectState, error) { + state, err := inspectCachedObject(path, file.Oid, file.Size) + if err != nil || !state.complete || file.SHA256 == "" { + return state, err + } + actual, err := calculateFileSHA256(path) + if err != nil { + return state, err + } + state.complete = strings.EqualFold(actual, file.SHA256) + return state, nil +} + +func verifyPointerAtPath(path string, file pointerFile) error { + if err := verifyObjectAtPath(path, file.Oid, file.Size); err != nil { + return err + } + if file.SHA256 == "" { + return nil + } + actual, err := calculateFileSHA256(path) + if err != nil { + return err + } + if !strings.EqualFold(actual, file.SHA256) { + return fmt.Errorf("sha256 mismatch: expected %s, got %s", file.SHA256, actual) + } + return nil +} + func isDRSPointerOID(oid string) bool { oid = strings.TrimSpace(oid) return strings.HasPrefix(oid, "//") || strings.HasPrefix(strings.ToLower(oid), "drs://") @@ -471,6 +526,12 @@ func refreshGitIndexForHydratedFiles(files []pointerFile) error { } func buildPullDownloadDebugContext(ctx context.Context, drsCtx *remoteruntime.GitContext, oid string) string { + if drsCtx == nil { + return fmt.Sprintf("oid=%s resolver=unavailable", oid) + } + if drsCtx.Client == nil { + return fmt.Sprintf("oid=%s resolver=%s", oid, drsCtx.RemoteType) + } recs, err := lookup.ObjectsByHashForScope(ctx, drsCtx, oid) if err != nil { return fmt.Sprintf("oid=%s query_error=%v", oid, err) @@ -499,7 +560,7 @@ func buildPullDownloadDebugContext(ctx context.Context, drsCtx *remoteruntime.Gi if am.AccessId != nil { accessID = strings.TrimSpace(*am.AccessId) } - methods = append(methods, fmt.Sprintf("{type=%s access_id=%s url_scheme=%s url=%s}", am.Type, accessID, scheme, rawURL)) + methods = append(methods, fmt.Sprintf("{type=%s access_id=%s url_scheme=%s}", am.Type, accessID, scheme)) } } return fmt.Sprintf("oid=%s did=%s size=%d access_methods=%s", oid, strings.TrimSpace(match.Id), match.Size, strings.Join(methods, ", ")) diff --git a/cmd/push/main.go b/cmd/push/main.go index a1758d09..950a3090 100644 --- a/cmd/push/main.go +++ b/cmd/push/main.go @@ -58,6 +58,9 @@ var Cmd = &cobra.Command{ return err } } + if selected, ok := cfg.Remotes[remote]; ok && selected.Terra != nil { + return fmt.Errorf("remote %q is read-only; publish AnVIL references with ordinary git push", remote) + } drsClient, err := remoteruntime.New(cfg, remote, myLogger) if err != nil { diff --git a/docs/anvil.md b/docs/anvil.md new file mode 100644 index 00000000..2c142ad7 --- /dev/null +++ b/docs/anvil.md @@ -0,0 +1,99 @@ +# Use AnVIL data references with git-drs + +This guide is for AnVIL users who want to version a dataset layout in Git +without copying controlled-access data into Git. A Git commit contains stable +DRS references and public resolver settings only. Each person downloads data +with their own Google identity and AnVIL authorization. + +## Before you begin + +Install Git, `git-drs`, the Google Cloud CLI, and obtain access to both the Git +repository and the referenced AnVIL data. Establish Application Default +Credentials (ADC) for your user: + +```bash +gcloud auth application-default login +``` + +ADC is local user state. Never copy the ADC JSON file into the repository. + +## Configure an AnVIL repository + +Create and commit `.git-drs/config.yaml`: + +```yaml +version: 1 +default_remote: anvil +remotes: + anvil: + type: terra + endpoint: https://data.terra.bio + auth: google-adc + mode: read-only +``` + +The tracked schema intentionally permits only the public Terra endpoint, +Google ADC authentication selection, and read-only mode. Tokens, arbitrary +headers, credential paths, signed URLs, unknown fields, non-HTTPS endpoints, +and embedded URL credentials are rejected. Clone-local Git configuration can +override public settings when an organization uses another trusted resolver. + +## Publish one reference + +Use the configured remote and choose the path that the data should occupy: + +```bash +git drs add-ref --remote anvil \ + drs:/// data/sample.cram +git add .git-drs/config.yaml .gitattributes data/sample.cram +git commit -m "Reference AnVIL sample" +git push +``` + +For a Terra remote, `add-ref` authenticates with your ADC, validates metadata, +and writes a small pointer. The pointer retains the canonical DRS URI even when +the record has a SHA256 checksum. It never contains an access token or signed +download URL. Destination paths must stay inside the Git repository. + +AnVIL remotes are read-only: publish pointers with ordinary `git push`. +`git drs push` refuses a Terra remote because that command uploads payloads to +writable DRS providers. + +## Clone and hydrate as another user + +The consumer authenticates independently and then hydrates all pointers: + +```bash +gcloud auth application-default login +git clone +cd +git drs pull +``` + +Hydrate only selected paths with an include pattern: + +```bash +git drs pull -I "data/*.cram" +``` + +Cloning the Git repository does **not** grant AnVIL data access. A user without +permission can inspect reference paths and DRS URIs but receives an +authorization error when hydration resolves the object. + +## Security and troubleshooting + +* `google application default credentials are unavailable`: run + `gcloud auth application-default login` as the current user and retry. +* `not authorized to access AnVIL DRS object`: confirm that the ADC identity has + access to the controlled dataset. Do not ask another user to share ADC files. +* `AnVIL DRS object not found`: verify the committed URI and that the configured + resolver supports its authority. +* Repository configuration errors are intentionally strict. Remove secret or + unknown fields from `.git-drs/config.yaml`; place supported local overrides + in Git configuration instead. +* Do not commit `.git/drs`, `.git/lfs`, Google credential files, bearer tokens, + request headers, or resolved access URLs. Access URLs are temporary and are + resolved again at download time. + +For pointer details, see [Pointer files](pointer-files.md). For the prototype +design and acceptance criteria, see [AnVIL/Terra POC](anvil-terra-poc.md). diff --git a/internal/config/config.go b/internal/config/config.go index 1589308f..ff7da793 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,9 @@ package config import ( "errors" "fmt" + "io" + "net/url" + "os" "os/exec" "path/filepath" "sort" @@ -10,6 +13,7 @@ import ( "github.com/calypr/git-drs/internal/gitrepo" "github.com/go-git/go-git/v5" + "gopkg.in/yaml.v3" ) // RemoteType represents the type of server being initialized @@ -283,6 +287,9 @@ func LoadConfig() (*Config, error) { cfg := &Config{ Remotes: make(map[Remote]RemoteSelect), } + if err := loadRepositoryConfig(cfg); err != nil { + return nil, err + } // Iterate over all sections to find 'drs' and its subsections for _, section := range conf.Raw.Sections { @@ -415,6 +422,70 @@ func getConfigPath() (string, error) { return "", err } - configPath := filepath.Join(topLevel, gitrepo.DRSDir, gitrepo.ConfigYAML) + configPath := filepath.Join(topLevel, gitrepo.RepoDRSDir, gitrepo.ConfigYAML) return configPath, nil } + +type repositoryConfig struct { + Version int `yaml:"version"` + DefaultRemote string `yaml:"default_remote"` + Remotes map[string]repositoryRemoteConfig `yaml:"remotes"` +} + +type repositoryRemoteConfig struct { + Type string `yaml:"type"` + Endpoint string `yaml:"endpoint"` + Auth string `yaml:"auth"` + Mode string `yaml:"mode"` +} + +// loadRepositoryConfig loads only the public, allowlisted clone-portable +// configuration. KnownFields deliberately fails closed for tokens, headers, +// credential paths, and future fields until they receive a security review. +func loadRepositoryConfig(cfg *Config) error { + path, err := getConfigPath() + if err != nil { + return err + } + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("open repository git-drs config: %w", err) + } + defer f.Close() + var raw repositoryConfig + dec := yaml.NewDecoder(io.LimitReader(f, 1<<20)) + dec.KnownFields(true) + if err := dec.Decode(&raw); err != nil { + return fmt.Errorf("invalid repository config %s: %w", path, err) + } + if raw.Version != 1 { + return fmt.Errorf("invalid repository config %s: unsupported version %d", path, raw.Version) + } + if strings.TrimSpace(raw.DefaultRemote) != "" { + cfg.DefaultRemote = Remote(strings.TrimSpace(raw.DefaultRemote)) + } + for name, r := range raw.Remotes { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("invalid repository config %s: remote name cannot be empty", path) + } + if r.Type != "terra" { + return fmt.Errorf("invalid repository config %s: remote %q type must be terra", path, name) + } + if r.Auth != "google-adc" { + return fmt.Errorf("invalid repository config %s: remote %q auth must be google-adc", path, name) + } + if r.Mode != "read-only" { + return fmt.Errorf("invalid repository config %s: remote %q mode must be read-only", path, name) + } + u, err := url.ParseRequestURI(strings.TrimSpace(r.Endpoint)) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return fmt.Errorf("invalid repository config %s: remote %q endpoint must be an HTTPS URL without credentials", path, name) + } + cfg.Remotes[Remote(name)] = RemoteSelect{Terra: &TerraRemote{Endpoint: u.String(), Auth: r.Auth, Mode: r.Mode}} + } + return nil +} diff --git a/internal/config/repository_config_test.go b/internal/config/repository_config_test.go new file mode 100644 index 00000000..212dc46e --- /dev/null +++ b/internal/config/repository_config_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRepositoryConfigLoadsAndLocalGitOverrides(t *testing.T) { + repo := setupTestRepo(t) + dir := filepath.Join(repo, ".git-drs") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + data := "version: 1\ndefault_remote: anvil\nremotes:\n anvil:\n type: terra\n endpoint: https://data.terra.bio\n auth: google-adc\n mode: read-only\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(data), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".git/config"), append(mustRead(t, filepath.Join(repo, ".git/config")), []byte("\n[drs]\n\tdefault-remote = local-choice\n")...), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.GetRemote("anvil") == nil || cfg.DefaultRemote != "local-choice" { + t.Fatalf("repository remote or local override missing: %+v", cfg) + } +} + +func TestRepositoryConfigRejectsSecretFields(t *testing.T) { + repo := setupTestRepo(t) + dir := filepath.Join(repo, ".git-drs") + _ = os.Mkdir(dir, 0o755) + data := "version: 1\nremotes:\n anvil:\n type: terra\n endpoint: https://data.terra.bio\n auth: google-adc\n mode: read-only\n token: do-not-load\n" + _ = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(data), 0o644) + _, err := LoadConfig() + if err == nil || !strings.Contains(err.Error(), "field token not found") { + t.Fatalf("expected fail-closed secret rejection, got %v", err) + } +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/internal/gitrepo/paths.go b/internal/gitrepo/paths.go index ab2669e2..0eeac868 100644 --- a/internal/gitrepo/paths.go +++ b/internal/gitrepo/paths.go @@ -4,6 +4,7 @@ const ( LFSObjectsPath = ".git/lfs/objects" DRSObjectsPath = ".git/drs/lfs/objects" ConfigYAML = "config.yaml" + RepoDRSDir = ".git-drs" DRSLogFile = ".git/drs/git-drs.log" DRSDir = ".git/drs" ) diff --git a/internal/lfs/inventory.go b/internal/lfs/inventory.go index a197f78d..fcf89ca5 100644 --- a/internal/lfs/inventory.go +++ b/internal/lfs/inventory.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "github.com/calypr/git-drs/internal/drsobject" drsapi "github.com/calypr/syfon/apigen/client/drs" "github.com/calypr/syfon/client/hash" ) @@ -27,6 +28,7 @@ type LfsFileInfo struct { OidType string `json:"oid_type"` Oid string `json:"oid"` Version string `json:"version"` + SHA256 string `json:"sha256,omitempty"` } func IsLFSTracked(path string) (bool, error) { @@ -144,6 +146,7 @@ func GetWorktreeLfsFiles(logger *slog.Logger) (map[string]LfsFileInfo, error) { OidType: pointer.OidType, Oid: pointer.Oid, Version: pointer.Version, + SHA256: pointer.SHA256, } } return files, nil @@ -213,6 +216,7 @@ func addFilesFromPaths(ctx context.Context, repoDir, ref string, paths []string, OidType: pointer.OidType, Oid: pointer.Oid, Version: pointer.Version, + SHA256: pointer.SHA256, } } @@ -409,6 +413,7 @@ func readWorktreePointerInfo(repoDir, path string) (LfsFileInfo, bool) { OidType: pointer.OidType, Oid: pointer.Oid, Version: pointer.Version, + SHA256: pointer.SHA256, }, true } @@ -428,6 +433,7 @@ func readIndexPointerInfo(ctx context.Context, repoDir, path string) (LfsFileInf OidType: pointer.OidType, Oid: pointer.Oid, Version: pointer.Version, + SHA256: pointer.SHA256, }, true } @@ -488,6 +494,7 @@ type lfsPointer struct { OidType string Oid string Size int64 + SHA256 string } func parseLFSPointer(content string) (lfsPointer, bool) { @@ -518,6 +525,13 @@ func parseLFSPointer(content string) (lfsPointer, bool) { return lfsPointer{}, false } p.Size = sz + continue + } + if strings.HasPrefix(line, "sha256 ") { + p.SHA256 = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "sha256 "))) + if !sha256OIDRe.MatchString(p.SHA256) { + return lfsPointer{}, false + } } } @@ -589,6 +603,9 @@ func CreateDRSPointer(drsObj *drsapi.DrsObject, dst string, drsURI string) error pointerContent := "version https://calypr.github.io/spec/v1\n" pointerContent += fmt.Sprintf("oid drs:%s\n", pointerOID) pointerContent += fmt.Sprintf("size %d\n", drsObj.Size) + if checksum := drsobject.NormalizeChecksum(hash.ConvertDrsChecksumsToHashInfo(drsObj.Checksums).SHA256); checksum != "" { + pointerContent += fmt.Sprintf("sha256 %s\n", strings.ToLower(checksum)) + } if err := os.WriteFile(dst, []byte(pointerContent), 0644); err != nil { return fmt.Errorf("failed to write DRS pointer file: %w", err) } diff --git a/internal/lfs/object_path.go b/internal/lfs/object_path.go index 1f25ac05..b7b1ba13 100644 --- a/internal/lfs/object_path.go +++ b/internal/lfs/object_path.go @@ -34,7 +34,7 @@ func cacheKeyForOID(oid string) (string, error) { } if isDRSURI(oid) { - sum := sha256.Sum256([]byte(normalizeDRSURI(oid))) + sum := sha256.Sum256([]byte("git-drs-anvil-ref:v1\n" + normalizeDRSURI(oid))) return hex.EncodeToString(sum[:]), nil } diff --git a/internal/lfs/terra_pointer_acceptance_test.go b/internal/lfs/terra_pointer_acceptance_test.go index 7112c866..b9b72473 100644 --- a/internal/lfs/terra_pointer_acceptance_test.go +++ b/internal/lfs/terra_pointer_acceptance_test.go @@ -23,6 +23,17 @@ size 11305017366 } } +func TestAnVILCacheIdentityGolden(t *testing.T) { + got, err := cacheKeyForOID("DRS://example.org/object-1") + if err != nil { + t.Fatal(err) + } + const want = "d644000bac79ca85d486629f9271ebf2da9884b7bca0d8ad1ee514f9241cbc74" + if got != want { + t.Fatalf("cache identity changed: got %s, want %s", got, want) + } +} + func TestAcceptanceTerraDRSURIoidPointerHasDeterministicSHA256CacheKey(t *testing.T) { const drsURI = "drs://cgc-ga4gh-api.sbgenomics.com/4c33ae65e4b08832ce3d94e9c" diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 00000000..2511ba70 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,237 @@ +// Package resolver provides provider-neutral DRS metadata and access resolution. +package resolver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +var ( + ErrCredentials = errors.New("google application default credentials are unavailable") + ErrUnauthorized = errors.New("not authorized to access AnVIL DRS object") + ErrNotFound = errors.New("AnVIL DRS object not found") +) + +type Resolver interface { + GetObject(context.Context, string) (*ResolvedObject, error) + GetAccess(context.Context, string, string) (*ResolvedAccess, error) +} + +// DownloadToCache resolves a fresh access URL, streams into a temporary file, +// and atomically promotes it. Neither the URL nor its headers are persisted. +func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string) error { + obj, err := r.GetObject(ctx, drsURI) + if err != nil { + return err + } + if len(obj.AccessMethods) == 0 || obj.AccessMethods[0].AccessID == "" { + return fmt.Errorf("AnVIL DRS object has no supported access method") + } + access, err := r.GetAccess(ctx, drsURI, obj.AccessMethods[0].AccessID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(destination), ".git-drs-download-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, access.URL, nil) + if err != nil { + tmp.Close() + return err + } + for key, value := range access.Headers { + req.Header.Set(key, value) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + tmp.Close() + return fmt.Errorf("AnVIL data download failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + tmp.Close() + return fmt.Errorf("AnVIL data download returned HTTP %d", resp.StatusCode) + } + _, copyErr := io.Copy(tmp, resp.Body) + closeErr := tmp.Close() + if copyErr != nil { + return fmt.Errorf("AnVIL data download interrupted: %w", copyErr) + } + if closeErr != nil { + return closeErr + } + if obj.Size >= 0 { + info, statErr := os.Stat(tmpName) + if statErr != nil { + return statErr + } + if info.Size() != obj.Size { + return fmt.Errorf("AnVIL data size mismatch: expected %d, got %d", obj.Size, info.Size()) + } + } + return os.Rename(tmpName, destination) +} + +type AccessMethod struct { + Type string `json:"type"` + AccessID string `json:"access_id"` +} + +type ResolvedObject struct { + DRSURI string + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + Checksums []Checksum `json:"checksums"` + AccessMethods []AccessMethod `json:"access_methods"` +} + +type Checksum struct { + Type string `json:"type"` + Checksum string `json:"checksum"` +} + +type ResolvedAccess struct { + URL string `json:"url"` + Headers map[string]string `json:"headers"` +} + +// AnVILResolver routes every DRS authority through the configured trusted +// Terra/AnVIL resolver endpoint. Authorization is attached only by the HTTP +// client created for that endpoint; access URLs are returned ephemerally. +type AnVILResolver struct { + endpoint *url.URL + client *http.Client +} + +func NewAnVIL(ctx context.Context, endpoint string) (*AnVILResolver, error) { + u, err := trustedEndpoint(endpoint) + if err != nil { + return nil, err + } + creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform") + if err != nil { + return nil, fmt.Errorf("%w: run `gcloud auth application-default login`: %v", ErrCredentials, err) + } + return &AnVILResolver{endpoint: u, client: &http.Client{Transport: &oauth2.Transport{Base: http.DefaultTransport, Source: creds.TokenSource}}}, nil +} + +// NewAnVILWithClient supports contract tests and callers that already own an +// authenticated, refreshing client. +func NewAnVILWithClient(endpoint string, client *http.Client) (*AnVILResolver, error) { + u, err := trustedEndpoint(endpoint) + if err != nil { + return nil, err + } + if client == nil { + return nil, fmt.Errorf("HTTP client is required") + } + return &AnVILResolver{endpoint: u, client: client}, nil +} + +func trustedEndpoint(endpoint string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil { + return nil, fmt.Errorf("invalid AnVIL resolver endpoint") + } + return u, nil +} + +func NormalizeDRSURI(raw string) (string, string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || !strings.EqualFold(u.Scheme, "drs") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" { + return "", "", fmt.Errorf("invalid DRS URI %q", raw) + } + id, err := url.PathUnescape(strings.TrimPrefix(u.EscapedPath(), "/")) + if err != nil || strings.TrimSpace(id) == "" { + return "", "", fmt.Errorf("invalid DRS URI %q: object ID is required", raw) + } + u.Scheme = "drs" + u.Host = strings.ToLower(u.Host) + return u.String(), id, nil +} + +func (r *AnVILResolver) GetObject(ctx context.Context, drsURI string) (*ResolvedObject, error) { + canonical, id, err := NormalizeDRSURI(drsURI) + if err != nil { + return nil, err + } + var obj ResolvedObject + if err := r.getJSON(ctx, r.apiURL("objects", id), &obj); err != nil { + return nil, err + } + obj.DRSURI = canonical + if obj.ID == "" { + obj.ID = id + } + return &obj, nil +} + +func (r *AnVILResolver) GetAccess(ctx context.Context, drsURI, accessID string) (*ResolvedAccess, error) { + _, id, err := NormalizeDRSURI(drsURI) + if err != nil { + return nil, err + } + if strings.TrimSpace(accessID) == "" { + return nil, fmt.Errorf("access ID is required") + } + var access ResolvedAccess + if err := r.getJSON(ctx, r.apiURL("objects", id, "access", accessID), &access); err != nil { + return nil, err + } + if strings.TrimSpace(access.URL) == "" { + return nil, fmt.Errorf("AnVIL resolver returned an empty access URL") + } + return &access, nil +} + +func (r *AnVILResolver) apiURL(parts ...string) string { + u := *r.endpoint + segments := append([]string{u.Path, "ga4gh", "drs", "v1"}, parts...) + u.Path = path.Join(segments...) + return u.String() +} + +func (r *AnVILResolver) getJSON(ctx context.Context, endpoint string, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + resp, err := r.client.Do(req) + if err != nil { + return fmt.Errorf("AnVIL resolver unavailable: %w", err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return ErrUnauthorized + case http.StatusNotFound: + return ErrNotFound + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("AnVIL resolver returned HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(dst); err != nil { + return fmt.Errorf("decode AnVIL resolver response: %w", err) + } + return nil +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go new file mode 100644 index 00000000..1f3a429a --- /dev/null +++ b/internal/resolver/resolver_test.go @@ -0,0 +1,51 @@ +package resolver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAnVILResolverContract(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ga4gh/drs/v1/objects/object-1": + _, _ = w.Write([]byte(`{"id":"object-1","size":4,"checksums":[{"type":"sha256","checksum":"abcd"}],"access_methods":[{"type":"https","access_id":"a1"}]}`)) + case "/ga4gh/drs/v1/objects/object-1/access/a1": + _, _ = w.Write([]byte(`{"url":"https://storage.example/signed"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + r, err := NewAnVILWithClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + obj, err := r.GetObject(context.Background(), "DRS://AUTHORITY.EXAMPLE/object-1") + if err != nil { + t.Fatal(err) + } + if obj.DRSURI != "drs://authority.example/object-1" || obj.Size != 4 || obj.AccessMethods[0].AccessID != "a1" { + t.Fatalf("unexpected object: %+v", obj) + } + access, err := r.GetAccess(context.Background(), obj.DRSURI, "a1") + if err != nil || access.URL != "https://storage.example/signed" { + t.Fatalf("unexpected access result: %+v, %v", access, err) + } +} + +func TestAnVILResolverRedactsErrorBodiesAndClassifiesAuthorization(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Bearer secret signed=https://secret", http.StatusForbidden) + })) + defer server.Close() + r, _ := NewAnVILWithClient(server.URL, server.Client()) + _, err := r.GetObject(context.Background(), "drs://example.org/object") + if !errors.Is(err, ErrUnauthorized) || strings.Contains(err.Error(), "secret") { + t.Fatalf("expected redacted authorization error, got %v", err) + } +} From 1d833f85748a17ba13cbbb209a10d8fcec1f6bae Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 14:35:06 -0700 Subject: [PATCH 17/66] Fix unknown flag error in git drs command --- cmd/remote/add/init.go | 8 +++++ cmd/remote/add/terra.go | 58 ++++++++++++++++++++++++++++++++++++ cmd/remote/add/terra_test.go | 58 ++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 cmd/remote/add/terra.go create mode 100644 cmd/remote/add/terra_test.go diff --git a/cmd/remote/add/init.go b/cmd/remote/add/init.go index 4248b546..514a01e7 100644 --- a/cmd/remote/add/init.go +++ b/cmd/remote/add/init.go @@ -9,6 +9,9 @@ var ( localPassword string localUsername string noSkipSmudge bool + terraEndpoint string + terraAuth string + terraMode string ) // Cmd line declaration @@ -29,4 +32,9 @@ func init() { LocalCmd.Flags().StringVar(&localPassword, "password", "", "Password for local DRS HTTP basic auth") LocalCmd.Flags().BoolVar(&noSkipSmudge, "no-skip-smudge", false, "Disable skipping smudge filter (force downloading file contents during checkout)") Cmd.AddCommand(LocalCmd) + + TerraCmd.Flags().StringVar(&terraEndpoint, "drs-endpoint", "", "Terra DRS service base URL") + TerraCmd.Flags().StringVar(&terraAuth, "auth", "", "Terra authentication method (google-adc)") + TerraCmd.Flags().StringVar(&terraMode, "mode", "", "Terra remote mode (read-only)") + Cmd.AddCommand(TerraCmd) } diff --git a/cmd/remote/add/terra.go b/cmd/remote/add/terra.go new file mode 100644 index 00000000..c3da6892 --- /dev/null +++ b/cmd/remote/add/terra.go @@ -0,0 +1,58 @@ +package add + +import ( + "fmt" + "net/url" + "strings" + + "github.com/calypr/git-drs/cmd/initialize" + "github.com/calypr/git-drs/internal/config" + "github.com/calypr/git-drs/internal/drslog" + "github.com/spf13/cobra" +) + +var TerraCmd = &cobra.Command{ + Use: "terra ", + Short: "Add a read-only Terra DRS remote", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + remoteName := strings.TrimSpace(args[0]) + if remoteName == "" { + return fmt.Errorf("remote name is required") + } + endpoint, err := validateTerraOptions(terraEndpoint, terraAuth, terraMode) + if err != nil { + return err + } + if err := initialize.EnsureInitialized(drslog.GetLogger()); err != nil { + return fmt.Errorf("failed to initialize repository: %w", err) + } + + newConfig, err := config.UpdateRemote(config.Remote(remoteName), config.RemoteSelect{ + Terra: &config.TerraRemote{Endpoint: endpoint, Auth: terraAuth, Mode: terraMode}, + }) + if err != nil { + return fmt.Errorf("failed to update remote config: %w", err) + } + fmt.Printf("Added remote '%s'. Config: %v\n", remoteName, newConfig.GetRemote(config.Remote(remoteName))) + return nil + }, +} + +func validateTerraOptions(endpoint, auth, mode string) (string, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "", fmt.Errorf("--drs-endpoint is required") + } + u, err := url.ParseRequestURI(endpoint) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return "", fmt.Errorf("--drs-endpoint must be an HTTPS URL without credentials") + } + if auth != "google-adc" { + return "", fmt.Errorf("--auth must be google-adc") + } + if mode != "read-only" { + return "", fmt.Errorf("--mode must be read-only") + } + return u.String(), nil +} diff --git a/cmd/remote/add/terra_test.go b/cmd/remote/add/terra_test.go new file mode 100644 index 00000000..c203a6e8 --- /dev/null +++ b/cmd/remote/add/terra_test.go @@ -0,0 +1,58 @@ +package add + +import ( + "testing" + + "github.com/calypr/git-drs/internal/config" + "github.com/calypr/git-drs/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddTerraRemoteCommand(t *testing.T) { + assert.Equal(t, "terra ", TerraCmd.Use) + assert.NotNil(t, TerraCmd.Flag("drs-endpoint")) + assert.NotNil(t, TerraCmd.Flag("auth")) + assert.NotNil(t, TerraCmd.Flag("mode")) +} + +func TestValidateTerraOptions(t *testing.T) { + endpoint, err := validateTerraOptions("https://data.terra.bio", "google-adc", "read-only") + require.NoError(t, err) + assert.Equal(t, "https://data.terra.bio", endpoint) + + for name, options := range map[string][3]string{ + "missing endpoint": {"", "google-adc", "read-only"}, + "HTTP endpoint": {"http://data.terra.bio", "google-adc", "read-only"}, + "URL credentials": {"https://user:pass@data.terra.bio", "google-adc", "read-only"}, + "unsupported auth": {"https://data.terra.bio", "token", "read-only"}, + "writable mode": {"https://data.terra.bio", "google-adc", "write"}, + } { + t.Run(name, func(t *testing.T) { + _, err := validateTerraOptions(options[0], options[1], options[2]) + assert.Error(t, err) + }) + } +} + +func TestTerraRemoteAddPersistsConfig(t *testing.T) { + testutils.SetupTestGitRepo(t) + terraEndpoint = "https://data.terra.bio" + terraAuth = "google-adc" + terraMode = "read-only" + t.Cleanup(func() { + terraEndpoint = "" + terraAuth = "" + terraMode = "" + }) + + require.NoError(t, TerraCmd.RunE(TerraCmd, []string{"anvil"})) + cfg, err := config.LoadConfig() + require.NoError(t, err) + remote := cfg.Remotes[config.Remote("anvil")].Terra + require.NotNil(t, remote) + assert.Equal(t, "https://data.terra.bio", remote.Endpoint) + assert.Equal(t, "google-adc", remote.Auth) + assert.Equal(t, "read-only", remote.Mode) + assert.Equal(t, config.Remote("anvil"), cfg.DefaultRemote) +} From c3fe7f3db3863a500837ab70ea699c8f934777db Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 15:51:01 -0700 Subject: [PATCH 18/66] fix drs uri validation --- internal/resolver/resolver.go | 23 ++++++++++++++++++++++- internal/resolver/resolver_test.go | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 2511ba70..f06e6afc 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -156,7 +156,28 @@ func trustedEndpoint(endpoint string) (*url.URL, error) { } func NormalizeDRSURI(raw string) (string, string, error) { - u, err := url.Parse(strings.TrimSpace(raw)) + trimmed := strings.TrimSpace(raw) + // DRS compact identifiers use a colon between the authority and object ID + // (for example, drs://drs.anv0:v2_...). net/url interprets that colon as + // the beginning of a port and rejects the URI before we can extract the ID. + if len(trimmed) >= len("drs://") && strings.EqualFold(trimmed[:len("drs://")], "drs://") { + remainder := trimmed[len("drs://"):] + if separator := strings.IndexByte(remainder, ':'); separator >= 0 && !strings.Contains(remainder[:separator], "/") { + authority, escapedID := remainder[:separator], remainder[separator+1:] + authorityURL, authorityErr := url.Parse("https://" + authority) + id, idErr := url.PathUnescape(escapedID) + if authorityErr != nil || authorityURL.Host != authority || authorityURL.Hostname() == "" || authorityURL.User != nil || + strings.ContainsAny(escapedID, "?#/") { + return "", "", fmt.Errorf("invalid DRS URI %q", raw) + } + if idErr != nil || strings.TrimSpace(id) == "" { + return "", "", fmt.Errorf("invalid DRS URI %q: object ID is required", raw) + } + return "drs://" + strings.ToLower(authority) + ":" + escapedID, id, nil + } + } + + u, err := url.Parse(trimmed) if err != nil || !strings.EqualFold(u.Scheme, "drs") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" { return "", "", fmt.Errorf("invalid DRS URI %q", raw) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 1f3a429a..11eb3b73 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -38,6 +38,29 @@ func TestAnVILResolverContract(t *testing.T) { } } +func TestAnVILResolverAcceptsCompactDRSURI(t *testing.T) { + const compactURI = "drs://drs.anv0:v2_e68887be-c583-375a-a773-48771192c8fa" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ga4gh/drs/v1/objects/v2_e68887be-c583-375a-a773-48771192c8fa" { + t.Fatalf("unexpected resolver path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"id":"v2_e68887be-c583-375a-a773-48771192c8fa","size":42}`)) + })) + defer server.Close() + + r, err := NewAnVILWithClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + obj, err := r.GetObject(context.Background(), compactURI) + if err != nil { + t.Fatalf("compact DRS URI should be valid: %v", err) + } + if obj.DRSURI != compactURI || obj.ID != "v2_e68887be-c583-375a-a773-48771192c8fa" { + t.Fatalf("unexpected object: %+v", obj) + } +} + func TestAnVILResolverRedactsErrorBodiesAndClassifiesAuthorization(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "Bearer secret signed=https://secret", http.StatusForbidden) From 4563ac8411b307a4af15c51088f4f6446af54140 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 16:35:11 -0700 Subject: [PATCH 19/66] fix clean-filter bug --- cmd/addref/add-ref.go | 6 +++++ cmd/addref/add-ref_test.go | 49 +++++++++++++++++++++++++++++++++++ docs/anvil.md | 1 + docs/commands.md | 4 +++ docs/troubleshooting.md | 21 +++++++++++++++ internal/filter/clean.go | 8 ++++-- internal/filter/clean_test.go | 29 +++++++++++++++++++++ internal/lfs/object_path.go | 6 +++-- 8 files changed, 120 insertions(+), 4 deletions(-) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 09545e9d..f181e473 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -116,6 +116,9 @@ var Cmd = &cobra.Command{ } else if err := lfs.CreateDRSPointer(&obj, dstPath, drsUri); err != nil { return err } + if _, err := gitrepo.TrackReadOnly(cmd.Context(), args[1]); err != nil { + return fmt.Errorf("track add-ref destination %s: %w", args[1], err) + } if obj.SelfUri == "" { obj.SelfUri = drsUri } @@ -273,6 +276,9 @@ func runManifest(cmd *cobra.Command, filename string) error { if err != nil { return err } + if _, err := gitrepo.TrackReadOnly(cmd.Context(), e.path); err != nil { + return fmt.Errorf("track add-ref destination %s: %w", e.path, err) + } } fmt.Fprintf(cmd.OutOrStdout(), "%d reference(s) %s\n", len(entries), map[bool]string{true: "validated", false: "added"}[dryRun]) return nil diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index 6f7cf772..c18828c4 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -5,12 +5,14 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "testing" "github.com/calypr/git-drs/internal/config" "github.com/calypr/git-drs/internal/drslog" + "github.com/calypr/git-drs/internal/gitrepo" "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/remoteruntime" drsapi "github.com/calypr/syfon/apigen/client/drs" @@ -75,6 +77,53 @@ func TestCreateDRSPointerPreservesSourceURI(t *testing.T) { } } +func TestAddRefTrackingMakesDRSPointerDiscoverable(t *testing.T) { + repo := t.TempDir() + runGitCmd(t, repo, "init") + runGitCmd(t, repo, "config", "user.email", "test@example.com") + runGitCmd(t, repo, "config", "user.name", "Test User") + + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repo); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + const path = "population_descriptor.tsv" + obj := &drsapi.DrsObject{Size: 200184} + if err := lfs.CreateDRSPointer(obj, path, "drs://drs.anv0:v2_example"); err != nil { + t.Fatal(err) + } + if _, err := gitrepo.TrackReadOnly(context.Background(), path); err != nil { + t.Fatal(err) + } + runGitCmd(t, repo, "add", ".gitattributes", path) + + files, err := lfs.GetTrackedLfsFiles(drslog.NewNoOpLogger()) + if err != nil { + t.Fatal(err) + } + info, ok := files[path] + if !ok { + t.Fatalf("expected add-ref pointer to be discoverable, got %+v", files) + } + if info.OidType != "drs" || info.Oid != "//drs.anv0:v2_example" || info.Size != 200184 { + t.Fatalf("unexpected pointer inventory: %+v", info) + } +} + +func runGitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v: %s", args, err, out) + } +} + func TestResolveAddRefObjectUsesSourceAuthorityRemoteCredentials(t *testing.T) { var primaryRequests int primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/anvil.md b/docs/anvil.md index 2c142ad7..c51f5217 100644 --- a/docs/anvil.md +++ b/docs/anvil.md @@ -45,6 +45,7 @@ Use the configured remote and choose the path that the data should occupy: ```bash git drs add-ref --remote anvil \ drs:/// data/sample.cram +# Run this only after add-ref finishes successfully. git add .git-drs/config.yaml .gitattributes data/sample.cram git commit -m "Reference AnVIL sample" git push diff --git a/docs/commands.md b/docs/commands.md index e2cba9fe..fe371139 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -287,6 +287,10 @@ Add a local pointer file for an existing DRS object. If the source DRS object ha git drs add-ref drs://example/object-id data/object.bin ``` +`add-ref` also adds an exact read-only `filter=drs` rule for the destination to +`.gitattributes`. Stage that file together with the pointer so local inventory +commands such as `git drs ls-files` can discover the reference. + ### `git drs query ` Query a DRS object by ID or checksum. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a65142f7..63e30573 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -188,6 +188,11 @@ git drs pull ### `git drs ls-files` does not show my file +This is not expected for a pointer created by a current `git drs add-ref`: +`add-ref` automatically adds its destination to `.gitattributes`. For pointers +created with an older version, add the tracking rule with +`git drs track path/to/file` and stage `.gitattributes`. + Check these in order: 1. is the path actually tracked? @@ -214,6 +219,22 @@ git ls-files -- path/to/file git drs ls-files -l ``` +### `git add ... git drs add-ref --remote ...` reports `unknown option 'remote'` + +`git add` and `git drs add-ref` are separate commands. If they are entered on +the same command line, Git interprets `--remote` as an option to `git add`, +which does not have that option. + +Create the reference first, then stage the generated pointer and tracking rule: + +```bash +git drs add-ref --remote anvil drs://drs.anv0:v2_example subject.tsv +git add .gitattributes subject.tsv +git commit -m "Add subject.tsv reference" +``` + +Do not prefix the `git drs add-ref` command with `git add`. + ### `git remote remove` did not remove my `git-drs` remote That is expected. diff --git a/internal/filter/clean.go b/internal/filter/clean.go index bb0b019d..15f66ca9 100644 --- a/internal/filter/clean.go +++ b/internal/filter/clean.go @@ -73,8 +73,12 @@ func CleanContent(_ context.Context, lfsRoot, pathname string, content io.Reader if _, err := dst.Write(data); err != nil { return fmt.Errorf("clean: write existing pointer: %w", err) } - if mapErr := writeDrsMap(pathname, pointerOID, pointerSize); mapErr != nil { - logger.Warn("clean: failed to write DRS map entry for existing pointer", "pathname", pathname, "error", mapErr) + // DRS URI pointers already carry their durable lookup identity. The + // SHA256-keyed sidecar map is only applicable to SHA256 pointers. + if !lfs.IsDRSURI(pointerOID) { + if mapErr := writeDrsMap(pathname, pointerOID, pointerSize); mapErr != nil { + logger.Warn("clean: failed to write DRS map entry for existing pointer", "pathname", pathname, "error", mapErr) + } } logger.Debug("clean: passed through existing LFS pointer", "pathname", pathname, "oid", pointerOID, "size", pointerSize) return nil diff --git a/internal/filter/clean_test.go b/internal/filter/clean_test.go index fbfd1766..0c03dea6 100644 --- a/internal/filter/clean_test.go +++ b/internal/filter/clean_test.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "testing" "github.com/calypr/git-drs/internal/drsobject" @@ -69,3 +70,31 @@ func TestCleanContentPassesThroughExistingPointer(t *testing.T) { } } } + +func TestCleanContentPassesThroughDRSURIWithoutSHA256MapWarning(t *testing.T) { + repo := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer os.Chdir(orig) + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir: %v", err) + } + + const pointer = "version https://calypr.github.io/spec/v1\n" + + "oid drs://drs.anv0:v2_e68887be-c583-375a-a773-48771192c8fa\n" + + "size 200184\n" + var out bytes.Buffer + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + if err := CleanContent(context.Background(), filepath.Join(repo, ".git", "lfs"), "population_descriptor.tsv", bytes.NewBufferString(pointer), &out, logger); err != nil { + t.Fatalf("CleanContent returned error: %v", err) + } + if out.String() != pointer { + t.Fatalf("expected DRS pointer passthrough, got %q", out.String()) + } + if strings.Contains(logs.String(), "failed to write DRS map entry") { + t.Fatalf("DRS URI pointer must not be written to the SHA256 map: %s", logs.String()) + } +} diff --git a/internal/lfs/object_path.go b/internal/lfs/object_path.go index b7b1ba13..c2031959 100644 --- a/internal/lfs/object_path.go +++ b/internal/lfs/object_path.go @@ -33,7 +33,7 @@ func cacheKeyForOID(oid string) (string, error) { return strings.ToLower(oid), nil } - if isDRSURI(oid) { + if IsDRSURI(oid) { sum := sha256.Sum256([]byte("git-drs-anvil-ref:v1\n" + normalizeDRSURI(oid))) return hex.EncodeToString(sum[:]), nil } @@ -41,7 +41,9 @@ func cacheKeyForOID(oid string) (string, error) { return "", fmt.Errorf("error: %s is not a valid sha256 hash or DRS URI", oid) } -func isDRSURI(uri string) bool { +// IsDRSURI reports whether uri is a canonical DRS URI or the //authority/path +// form stored internally after parsing a git-drs pointer's "oid drs:" line. +func IsDRSURI(uri string) bool { uri = strings.TrimSpace(uri) return strings.HasPrefix(strings.ToLower(uri), "drs://") || strings.HasPrefix(uri, "//") } From 5bb0921dc4bde0bcc8778175f5a8b87f3a7396ca Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 17:06:47 -0700 Subject: [PATCH 20/66] Keep hydrated DRS URI references clean. --- cmd/pull/pull_test.go | 10 +++-- internal/filter/clean.go | 73 +++++++++++++++++++++++++++++++++++ internal/filter/clean_test.go | 57 +++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/cmd/pull/pull_test.go b/cmd/pull/pull_test.go index dd77e67b..c6c7fe49 100644 --- a/cmd/pull/pull_test.go +++ b/cmd/pull/pull_test.go @@ -335,9 +335,13 @@ func TestRefreshGitIndexForHydratedFilesClearsDirtyStatus(t *testing.T) { worktreePath := filepath.Join(repo, "sample.bin") payload := []byte("hello world payload") - sum := sha256.Sum256(payload) - oid := hex.EncodeToString(sum[:]) - writePointerFile(t, worktreePath, oid, strconv.Itoa(len(payload))) + oid := "drs://drs.anv0:v2_example-without-sha256" + pointer := "version https://calypr.github.io/spec/v1\n" + + "oid " + oid + "\n" + + "size " + strconv.Itoa(len(payload)) + "\n" + if err := os.WriteFile(worktreePath, []byte(pointer), 0o644); err != nil { + t.Fatalf("write DRS pointer: %v", err) + } runGitCmdTest(t, repo, "add", ".gitattributes", "sample.bin") runGitCmdTest(t, repo, "commit", "-m", "commit pointer") diff --git a/internal/filter/clean.go b/internal/filter/clean.go index 15f66ca9..5d7d4004 100644 --- a/internal/filter/clean.go +++ b/internal/filter/clean.go @@ -8,7 +8,10 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" + "strconv" + "strings" "github.com/calypr/git-drs/internal/drsobject" "github.com/calypr/git-drs/internal/gitrepo" @@ -67,6 +70,19 @@ func CleanContent(_ context.Context, lfsRoot, pathname string, content io.Reader size := written oid := hex.EncodeToString(h.Sum(nil)) + // A DRS pointer can use the durable DRS URI as its oid rather than the + // payload checksum. When Git asks the clean filter to inspect a hydrated + // file, preserve that indexed pointer if its recorded checksum proves the + // worktree payload is unchanged. Otherwise hydration would turn the URI + // pointer into a SHA256 LFS pointer and appear as a staged modification. + if pointer, ok := matchingIndexedDRSPointer(pathname, oid, size); ok { + if _, err := dst.Write(pointer); err != nil { + return fmt.Errorf("clean: write indexed DRS pointer: %w", err) + } + logger.Debug("clean: restored indexed DRS pointer for hydrated content", "pathname", pathname, "oid", oid, "size", size) + return nil + } + if size > 0 && size < 2048 { if data, readErr := os.ReadFile(tmpPath); readErr == nil { if pointerOID, pointerSize, ok := lfs.ParseLFSPointer(data); ok { @@ -113,3 +129,60 @@ func CleanContent(_ context.Context, lfsRoot, pathname string, content io.Reader return nil } + +func matchingIndexedDRSPointer(pathname, contentOID string, size int64) ([]byte, bool) { + pathname = filepath.ToSlash(filepath.Clean(pathname)) + if pathname == "." || pathname == ".." || strings.HasPrefix(pathname, "../") { + return nil, false + } + cmd := exec.Command("git", "show", ":"+pathname) + pointer, err := cmd.Output() + if err != nil { + return nil, false + } + pointerOID, pointerSize, ok := lfs.ParseLFSPointer(pointer) + if !ok || !lfs.IsDRSURI(pointerOID) || pointerSize != size { + return nil, false + } + // Some DRS services do not publish a SHA256 checksum. In that case compare + // the worktree payload with the validated object cached by pull. This keeps + // status clean without hiding a same-sized edit made after hydration. + if cachedOID, ok := cachedObjectOID(pointerOID, size); ok && cachedOID == contentOID { + return pointer, true + } + for _, line := range strings.Split(string(pointer), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.EqualFold(fields[0], "sha256") { + checksum := strings.TrimPrefix(strings.ToLower(fields[1]), "sha256:") + if checksum == contentOID { + return pointer, true + } + } + if len(fields) == 2 && fields[0] == "size" { + // Reject malformed sizes even if a future pointer parser becomes + // more permissive. + if parsed, err := strconv.ParseInt(fields[1], 10, 64); err != nil || parsed != size { + return nil, false + } + } + } + return nil, false +} + +func cachedObjectOID(pointerOID string, size int64) (string, bool) { + cachePath, err := lfs.ObjectPath(gitrepo.LFSObjectsPath, pointerOID) + if err != nil { + return "", false + } + f, err := os.Open(cachePath) + if err != nil { + return "", false + } + defer f.Close() + h := sha256.New() + written, err := io.Copy(h, f) + if err != nil || written != size { + return "", false + } + return hex.EncodeToString(h.Sum(nil)), true +} diff --git a/internal/filter/clean_test.go b/internal/filter/clean_test.go index 0c03dea6..af7901d1 100644 --- a/internal/filter/clean_test.go +++ b/internal/filter/clean_test.go @@ -5,9 +5,11 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "io" "log/slog" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -71,6 +73,61 @@ func TestCleanContentPassesThroughExistingPointer(t *testing.T) { } } +func TestCleanContentPreservesIndexedDRSPointerForHydratedPayload(t *testing.T) { + repo := t.TempDir() + payload := []byte("hydrated population data\n") + pointer := "version https://calypr.github.io/spec/v1\n" + + "oid drs://drs.anv0:v2_example\n" + + "size " + fmt.Sprint(len(payload)) + "\n" + path := filepath.Join(repo, "population_descriptor.tsv") + if err := os.WriteFile(path, []byte(pointer), 0o644); err != nil { + t.Fatalf("write pointer: %v", err) + } + for _, args := range [][]string{{"init"}, {"add", "population_descriptor.tsv"}} { + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer os.Chdir(orig) + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir: %v", err) + } + cachePath, err := lfs.ObjectPath(gitrepo.LFSObjectsPath, "drs://drs.anv0:v2_example") + if err != nil { + t.Fatalf("ObjectPath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + if err := os.WriteFile(cachePath, payload, 0o644); err != nil { + t.Fatalf("write cache: %v", err) + } + var out bytes.Buffer + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := CleanContent(context.Background(), filepath.Join(repo, ".git", "lfs"), "population_descriptor.tsv", bytes.NewReader(payload), &out, logger); err != nil { + t.Fatalf("CleanContent: %v", err) + } + if out.String() != pointer { + t.Fatalf("expected indexed DRS pointer, got %q", out.String()) + } + + changed := bytes.Repeat([]byte("x"), len(payload)) + out.Reset() + if err := CleanContent(context.Background(), filepath.Join(repo, ".git", "lfs"), "population_descriptor.tsv", bytes.NewReader(changed), &out, logger); err != nil { + t.Fatalf("CleanContent changed payload: %v", err) + } + if out.String() == pointer { + t.Fatal("same-sized changed payload must not be hidden by the indexed DRS pointer") + } +} + func TestCleanContentPassesThroughDRSURIWithoutSHA256MapWarning(t *testing.T) { repo := t.TempDir() orig, err := os.Getwd() From 19475be275326ab92393039c51678e826adea937 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 17:34:15 -0700 Subject: [PATCH 21/66] adds presentation --- docs/anvil-terra-poc-presentation.html | 197 +++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/anvil-terra-poc-presentation.html diff --git a/docs/anvil-terra-poc-presentation.html b/docs/anvil-terra-poc-presentation.html new file mode 100644 index 00000000..a5b7df72 --- /dev/null +++ b/docs/anvil-terra-poc-presentation.html @@ -0,0 +1,197 @@ + + + + + + AnVIL + Terra × git-drs — reference POC + + + +
+
+
+

Reference proof of concept

+

AnVIL + Terra
meets git-drs

+

Version controlled dataset layouts—without copying controlled-access payloads into Git.

+ +
+ +
+

The idea in one picture

Git moves references. AnVIL moves bytes.

+
+
USER AAdd referencesADC-authorized metadata validation
+
GITCommit pointersCanonical DRS URI + size + checksum
+
USER BClone + authenticateIndependent Google identity
+
ANVILHydrateFresh authorized URL; verified bytes
+
+
0
payload bytes committed to Git
2
independent user identities
1
portable canonical reference
+

Security boundary: Git visibility reveals paths and DRS identifiers; it never grants authorization to the underlying data.

+
+ +
+

Use cases

One reference model, four perspectives

+
+

✦ Data reference author

Publish a reviewable, versioned dataset layout—one object or a manifest—without downloading payloads.

+

↓ Data consumer

Clone pointers, authenticate independently, then hydrate everything or only paths matching a pattern.

+

◉ Unauthorized reader

Inspect repository history but receive a clear denial when attempting controlled-data hydration.

+

✓ Repository maintainer

Review deterministic pointers, diagnose provider failures, and run clean-clone acceptance tests.

+
+ +
+ +
+

How to · author

Add once. Publish with ordinary Git.

+
+
gcloud auth application-default login + +git drs add-ref --remote anvil \ + drs://authority/object-1 data/sample.cram + +# or: validate a batch before writing +git drs add-ref --remote anvil \ + --manifest references.tsv --dry-run + +git add .git-drs/ .gitattributes data/ +git commit -m "Add AnVIL data references" +git push
+

What gets published

  • Canonical DRS URI pointers
  • Paths and Git history
  • .gitattributes
  • Allowlisted, non-secret remote configuration

What never gets published

  • Payload bytes or cache content
  • Tokens, headers, or ADC files
  • Signed download URLs
+
+
+ +
+

How to · consumer

A clean clone is enough

+
+
gcloud auth application-default login + +git clone <git-repository> +cd <repository> + +# hydrate every authorized reference +git drs pull + +# or only one dataset slice +git drs pull -I "data/*.cram"

No author cache, local Git config, token, or signed URL is transferred.

+
GIT
Clone pointer + safe public config
USER B
Supply ADC; choose all or include pattern
RESOLVER
Fetch current metadata and fresh access
CACHE
Download → verify → atomic promote
WORKTREE
Hydrate destination only after validation
+
+
+ +
+

Examples

Portable pointer + safe configuration

+
+

Tracked pointer

version https://calypr.github.io/spec/v1 +oid drs://authority/object-1 +size 987654321 +sha256 8d969eef…

The DRS URI stays canonical even when a SHA256 is available. A checksum describes content; it does not identify the AnVIL record.

+

Tracked .git-drs/config.yaml

version: 1 +default_remote: anvil +remotes: + anvil: + type: terra + endpoint: https://data.terra.bio + auth: google-adc + mode: read-only

Only allowlisted public settings load. Secret-like or unknown sensitive fields fail closed.

+
+
+ +
+

Architecture

Provider-neutral resolution breaks Syfon coupling

+ + + Commandsadd-ref · pull · ping + Resolver interfaceGetObject · GetAccess + Identityremote · cache · content + + AnVILResolverGoogle ADC · routing + SyfonResolverGen3 · local behavior + + +

Resolver selection comes from the configured remote—not a user-facing --remote-type switch.

+
+ +
+

Architecture detail

Three identities—not one overloaded OID

+
+
REMOTE

Canonical DRS URI

Normalized provider record identity. Committed to Git and used for object/access resolution.

+
LOCAL

Cache OID

Filesystem-safe SHA256 of a versioned prefix plus normalized DRS URI. Never replaces remote identity.

+
CONTENT

Content SHA256

Durable integrity metadata, when supplied. Used to verify bytes—not to locate the record.

+
+
cache_oid = sha256("git-drs-anvil-ref:v1\n" + normalized_drs_uri)
+

Download invariant: resolve fresh access → temporary file → size/checksum verify → atomic cache promotion → worktree hydration.

+
+ +
+

Tests

Good component coverage; one decisive gap

+
+
IMPLEMENTED

Focused coverage

  • Resolver contract + errors
  • Terra remote + safe config
  • Canonical pointer/cache key
  • Terra ping + push refusal
+
PARTIAL

Workflow coverage

  • Manifest validation + dry run
  • Pull size/SHA256 checks
  • Selective hydration
  • Cache reuse behavior
+
MISSING

POC acceptance

  • Independent User A/User B state
  • Real or contract-faithful AnVIL
  • Expired URL + retry journey
  • Authorization leak audit
+
+
ArrangeSeparate homes, config, ADC, caches
ActCommit → clone → authenticate → pull
AssertVerified bytes; isolated credentials
+
+ +
+

Implementation audit

Most original blockers are now closed

+
+

✓ Implemented

  • ADC-backed AnVILResolver handles metadata and access.
  • Terra add-ref and pull use provider-neutral resolution.
  • Remote config selects behavior; --remote-type is hidden and deprecated.
  • Terra pointers retain DRS URI, size, and optional SHA256.
  • DRS cache keys are derived separately from content checksums.
+

△ Still incomplete

  • Provider-neutral routing is not yet universal; legacy providers still use Syfon paths.
  • The production AnVIL endpoint/scopes/contract still require authoritative validation.
  • Manifest resolution is sequential; bounded concurrency is not implemented.
  • Downloads lack bounded retry and automatic expired-URL re-resolution.
  • No complete clean-clone, independent two-user acceptance test exists.
+
+

Bottom line: this is no longer an unwired design. It is an implemented vertical slice that still needs production-contract validation, resilience, and end-to-end proof.

+
+ +
+

Next steps

Turn the vertical slice into a proven POC

+
+
P0 · VERIFY

Prove production fit

  • Confirm endpoint + OAuth scopes
  • Certify resolver response contract
  • Choose stable test records
  • Run clean two-user clone/pull
  • Audit logs/history for secrets
+
P1 · HARDEN

Make it resilient

  • Expired-URL re-resolution
  • Bounded retry + cancellation
  • Bounded manifest concurrency
  • git drs doctor
  • CI + compatibility matrix
+
P2 · DEFER

Protect the scope

  • AnVIL uploads or mutation
  • Cross-provider copying
  • Remote GC
  • Workspace sync
  • All-provider redesign
+
+
+ +
+

Questions

Decisions needed to start the POC

+
01 · Which authoritative AnVIL resolver contract, endpoint, and OAuth scopes will we certify?
+
02 · What stable test objects cover checksummed, non-checksummed, large, and denied cases?
+
03 · Is the proposed pointer extension compatible with every parser that must read it?
+
04 · What is the supported override policy for tracked vs. local remote configuration?
+
05 · Who owns the independent-user acceptance environment and compatibility matrix?
+ +
+
+ + + + From e1ff2ee198f8f363e4951aa18a998fd26ba2b8fb Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 17:45:33 -0700 Subject: [PATCH 22/66] adds anvil manifest --- scripts/anvil-add-ref-commands.sh | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100755 scripts/anvil-add-ref-commands.sh diff --git a/scripts/anvil-add-ref-commands.sh b/scripts/anvil-add-ref-commands.sh new file mode 100755 index 00000000..f6aa91d3 --- /dev/null +++ b/scripts/anvil-add-ref-commands.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Generate git-drs add-ref commands from an AnVIL TSV export. + +Usage: + anvil-add-ref-commands.sh + +The input must contain columns named "files.drs_uri" and "files.file_name". +Commands are written to standard output; they are not executed. +EOF +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +split_tsv_line() { + local line=$1 + + # macOS ships Bash 3.2, which does not support namerefs. Return fields + # through this global array instead so the script works with both + # the system Bash on macOS and newer Bash releases. + tsv_fields=() + while [[ $line == *$'\t'* ]]; do + tsv_fields+=("${line%%$'\t'*}") + line=${line#*$'\t'} + done + tsv_fields+=("$line") +} + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + usage + exit 0 +fi + +[[ $# -eq 1 ]] || { + usage >&2 + exit 2 +} + +input=$1 +[[ -f $input ]] || die "TSV file not found: $input" +[[ -r $input ]] || die "TSV file is not readable: $input" + +IFS= read -r header_line < "$input" || die "TSV file is empty: $input" +header_line=${header_line%$'\r'} +header_line=${header_line#$'\xef\xbb\xbf'} + +declare -a headers +declare -a tsv_fields +split_tsv_line "$header_line" +headers=("${tsv_fields[@]}") + +drs_uri_index=-1 +file_name_index=-1 +for index in "${!headers[@]}"; do + case ${headers[$index]} in + files.drs_uri) drs_uri_index=$index ;; + files.file_name) file_name_index=$index ;; + esac +done + +((drs_uri_index >= 0)) || die 'required column not found: files.drs_uri' +((file_name_index >= 0)) || die 'required column not found: files.file_name' + +line_number=1 +while IFS= read -r line || [[ -n $line ]]; do + ((line_number += 1)) + line=${line%$'\r'} + [[ -n $line ]] || continue + + declare -a fields + split_tsv_line "$line" + fields=("${tsv_fields[@]}") + + drs_uri=${fields[$drs_uri_index]:-} + file_name=${fields[$file_name_index]:-} + [[ -n $drs_uri ]] || die "line $line_number has an empty files.drs_uri value" + [[ -n $file_name ]] || die "line $line_number has an empty files.file_name value" + + printf 'git drs add-ref --remote anvil %q %q\n' "$drs_uri" "$file_name" +done < <(tail -n +2 -- "$input") From d61dcde447dc7672ad4438fa702b5cae76a4e071 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 17:56:58 -0700 Subject: [PATCH 23/66] Add example AnVIL manifest workflow slide --- docs/anvil-terra-poc-presentation.html | 52 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/anvil-terra-poc-presentation.html b/docs/anvil-terra-poc-presentation.html index a5b7df72..b3784dcd 100644 --- a/docs/anvil-terra-poc-presentation.html +++ b/docs/anvil-terra-poc-presentation.html @@ -104,6 +104,46 @@

AnVIL + Terra
meets git-drs

+

Example user workflow

From AnVIL manifest to a pointer-only GitHub repo

+
+

1 · Configure and add references

git init + +# Configure the read-only Terra remote. +git drs remote add terra anvil \ + --drs-endpoint https://data.terra.bio \ + --auth google-adc \ + --mode read-only + +# Download the TSV from AnVIL Data Explorer first. +manifest=/tmp/anvil-manifest-38dc7537.tsv + +# Generate, review, then run one add-ref per row. +scripts/anvil-add-ref-commands.sh "$manifest" \ + > /tmp/add-anvil-refs.sh +cat /tmp/add-anvil-refs.sh +bash /tmp/add-anvil-refs.sh
+

2 · Commit, hydrate, and publish

# The generated commands create references such as: +# sequencing_dataset.tsv · subject.tsv +# sequencing_file.tsv · plink_file_wide.tsv +# sample.tsv · sample_set.tsv +# population_descriptor.tsv + +git add .git-drs/ .gitattributes '*.tsv' +git commit -m "Add references to AnVIL data" + +# Materialize only TSVs on local disk. +git drs pull -I "*.tsv" +git status + +git branch -M main +git remote add origin \ + https://github.com/bwalsh/ANVIL_1000G_PRIMED_data_model.git +git push -u origin main
+
+

Result: the local worktree contains hydrated TSV data; GitHub contains only small DRS pointers and safe public configuration—not the dataset payloads.

+
+ +

Examples

Portable pointer + safe configuration

Tracked pointer

version https://calypr.github.io/spec/v1 @@ -121,7 +161,7 @@

AnVIL + Terra
meets git-drs

-
+

Architecture

Provider-neutral resolution breaks Syfon coupling

@@ -136,7 +176,7 @@

AnVIL + Terra
meets git-drs

Resolver selection comes from the configured remote—not a user-facing --remote-type switch.

-
+

Architecture detail

Three identities—not one overloaded OID

REMOTE

Canonical DRS URI

Normalized provider record identity. Committed to Git and used for object/access resolution.

@@ -147,7 +187,7 @@

AnVIL + Terra
meets git-drs

Download invariant: resolve fresh access → temporary file → size/checksum verify → atomic cache promotion → worktree hydration.

-
+

Tests

Good component coverage; one decisive gap

IMPLEMENTED

Focused coverage

  • Resolver contract + errors
  • Terra remote + safe config
  • Canonical pointer/cache key
  • Terra ping + push refusal
@@ -157,7 +197,7 @@

AnVIL + Terra
meets git-drs

ArrangeSeparate homes, config, ADC, caches
ActCommit → clone → authenticate → pull
AssertVerified bytes; isolated credentials
-
+

Implementation audit

Most original blockers are now closed

✓ Implemented

  • ADC-backed AnVILResolver handles metadata and access.
  • Terra add-ref and pull use provider-neutral resolution.
  • Remote config selects behavior; --remote-type is hidden and deprecated.
  • Terra pointers retain DRS URI, size, and optional SHA256.
  • DRS cache keys are derived separately from content checksums.
@@ -166,7 +206,7 @@

AnVIL + Terra
meets git-drs

Bottom line: this is no longer an unwired design. It is an implemented vertical slice that still needs production-contract validation, resilience, and end-to-end proof.

-
+

Next steps

Turn the vertical slice into a proven POC

P0 · VERIFY

Prove production fit

  • Confirm endpoint + OAuth scopes
  • Certify resolver response contract
  • Choose stable test records
  • Run clean two-user clone/pull
  • Audit logs/history for secrets
@@ -175,7 +215,7 @@

AnVIL + Terra
meets git-drs

-
+

Questions

Decisions needed to start the POC

01 · Which authoritative AnVIL resolver contract, endpoint, and OAuth scopes will we certify?
02 · What stable test objects cover checksummed, non-checksummed, large, and denied cases?
From 1b7b7b097332e9414204117f5ea97c5a00a732dc Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 20 Jul 2026 18:13:46 -0700 Subject: [PATCH 24/66] adds docs/index.html --- docs/index.html | 109 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/index.html diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..4d5f70db --- /dev/null +++ b/docs/index.html @@ -0,0 +1,109 @@ + + + + + + Git DRS Documentation + + + +
+

Welcome to Git DRS

+

+ Git-based Data Repository Service (DRS) integration for Terra/Anvil. + Manage large files and data efficiently with DRS-enabled Git workflows. +

+ +
    +
  • Seamless DRS integration with Git
  • +
  • Support for multipart uploads and downloads
  • +
  • Terra/Anvil workspace compatibility
  • +
  • Efficient large file handling
  • +
+ +
+

Get started with our Terra/Anvil POC presentation:

+ View Presentation → +
+
+ + From 87c4cd836a88c9784afc773d62811eedbfd31160 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 10:31:00 -0700 Subject: [PATCH 25/66] docs: propose a unified remote CLI and multi-DRS architecture --- docs/remote-cli-design.md | 217 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/remote-cli-design.md diff --git a/docs/remote-cli-design.md b/docs/remote-cli-design.md new file mode 100644 index 00000000..845343c6 --- /dev/null +++ b/docs/remote-cli-design.md @@ -0,0 +1,217 @@ +# Remote CLI and multi-DRS server design + +## Status + +Proposed. This document describes a migration target rather than the current +command-line contract. + +## Problem + +`remote add` currently exposes the implementation as three different command +shapes: + +```text +git drs remote add gen3 [remote-name] +git drs remote add local +git drs remote add terra --drs-endpoint URL --auth google-adc --mode read-only +``` + +This creates several avoidable distinctions: + +- the provider is sometimes a command and sometimes effectively a deployment + choice; +- the endpoint is inferred, positional, or a flag depending on the provider; +- `--cred`, `--token`, `--username`, and `--password` encode credential formats + rather than a credential source; +- `--mode read-only` and `--auth google-adc` ask users to state the only values + currently accepted; +- `--bucket` mixes DRS resolution with Gen3 storage and publishing policy; +- `--no-skip-smudge` is repository checkout policy, not a remote property; and +- `local` describes where a server is deployed, not which DRS protocol it + implements. + +Adding Cancer Genomics Cloud (CGC), Synapse, or another GA4GH DRS service by +copying this pattern would produce another provider subcommand and another set +of slightly different flags for every service. + +## Proposed vocabulary + +Use one command with two required concepts: a local remote name and a server +endpoint (or a well-known endpoint alias). + +```text +git drs remote add [flags] + +Flags: + --scope Optional provider-specific collection/project scope + --auth auto, none, bearer, basic, google-adc, or provider-helper + --credential Credential source, never an inline secret + --provider auto, ga4gh, gen3, terra, cgc, or synapse + --storage Advanced write/registration storage override + --checkout pointers or hydrate +``` + +For example: + +```sh +# Provider and capabilities discovered from service-info where possible. +git drs remote add cgc https://cgc-ga4gh-api.sbgenomics.com \ + --auth bearer --credential env:CGC_TOKEN + +git drs remote add synapse https://repo-prod.prod.sagebase.org \ + --auth bearer --credential helper:synapse + +# A preset may supply a public endpoint and authentication default. +git drs remote add anvil terra --scope my-billing-project/my-workspace + +# Gen3 keeps its scope, but no longer needs a different grammar. +git drs remote add production https://example-gen3.org \ + --provider gen3 --scope PROGRAM/PROJECT \ + --credential file:~/.gen3/credentials.json +``` + +`` should be an HTTPS URL by default. Aliases such as +`terra`, `cgc`, and `synapse` are conveniences that expand to versioned, +maintained presets; they are not separate protocols. `--provider` is an escape +hatch when discovery is absent or ambiguous, and should normally remain +`auto`. + +### Defaults remove flags + +- Discover the provider and supported features using GA4GH service-info and a + small, maintained fingerprint table. Print the detected provider before + persisting configuration. +- Infer `--auth auto` from the preset, a 401 challenge, and installed credential + helpers. Do not silently downgrade from authenticated to anonymous access. +- Infer read-only versus writable from capability probing. Replace `--mode` + with reported capabilities rather than a user assertion. +- Default checkout behavior from the repository's existing configuration. + `--checkout` is an optional convenience on `remote add`; the canonical + setting belongs to a repository-level `config` command. +- Prompt for a storage choice only when a write operation actually needs one. + Keep `--storage` for automation, but do not make bucket selection part of + adding a read-only DRS resolver. + +### Credential sources + +One `--credential` option should name where credentials come from: + +```text +env:NAME read a token from an environment variable +file:PATH import/read a provider credential file +helper:NAME invoke an OS, Git, or provider credential helper +stdin read a secret without placing it in shell history +profile:NAME use an existing provider profile +``` + +Inline `--token` and `--password` values should be deprecated because they are +visible in process listings and shell history. Basic authentication can still +be supported by a helper that returns a username and password. Public tracked +configuration contains only endpoint, provider, scope, and safe capability +hints; credential source selection and secrets remain clone-local. + +## Separate the DRS protocol from provider extensions + +Model a remote as a composition instead of one provider-specific client: + +1. **Resolver** — the GA4GH DRS operations for object metadata and access + methods/URLs. +2. **Authenticator** — anonymous, bearer, basic, Google ADC, or a provider + helper that can refresh credentials. +3. **Namespace codec** — parsing and formatting the server's accepted DRS URI + authority and object identifiers. +4. **Publisher** — optional upload, registration, checksum search, bucket + mapping, and delete operations. These are not implied by DRS resolution. +5. **Capabilities** — discovered facts such as resolve, download, search, + register, upload, and delete, cached with the remote configuration and + rechecked by `git drs remote diagnose `. + +The generic resolver should implement the standard DRS object and access +flows. Provider adapters should be small and limited to real differences: +authentication, endpoint/URI normalization, pagination or response quirks, +and non-standard publishing APIs. Core code should select behavior by a +capability interface, not by tests such as `remote.type == "terra"`. + +This separation also makes errors useful. A CGC or Synapse remote can be a +perfectly valid read-only resolver even when it cannot support the current +Gen3 checksum-search and registration workflow. `push` should fail early with +"remote does not advertise register/upload" rather than treating resolution +success as evidence that publishing is available. + +## CGC and Synapse + +Both services should first be treated as GA4GH DRS resolvers, then augmented +only where their documented behavior requires it. + +### Cancer Genomics Cloud + +The CGC adapter should provide a preset for its DRS base URL, bearer-token +credential help, and its accepted DRS URI authority/object identifier form. +It should use the generic object and access flow. Any CGC project browsing, +file upload, or file registration API belongs in an optional publisher adapter, +because those operations are outside the baseline DRS resolver contract. + +The implementation must test whether an access response contains a directly +usable URL or requires the follow-up access endpoint, preserve documented +headers, and never persist a signed access URL. A project identifier, if needed +for non-DRS publishing, is `--scope`; it must not be required merely to resolve +a complete DRS URI. + +### Synapse + +The Synapse adapter should likewise provide its production endpoint/authority +preset and obtain a Synapse bearer credential through a helper. Resolution +should remain object-ID based and should not assume that a returned checksum is +itself a resolvable object ID. + +Synapse project/entity discovery and mutation are separate Synapse REST +capabilities. If later supported for publishing, they should be implemented by +a Synapse publisher and exposed through capability checks, not folded into the +generic DRS resolver or made mandatory during `remote add`. + +### Conformance fixtures + +Add provider contract tests built from sanitized documented responses for: + +- service-info discovery or the explicit fallback when it is unavailable; +- object metadata with one and multiple access methods; +- the access endpoint and expiring URL handling; +- bearer authentication and redaction of tokens and response bodies; +- 401/403, 404, 429, and server-error classification; +- DRS URI authority and percent-encoding normalization; and +- missing SHA-256 checksums (the canonical DRS URI remains the retrieval + identity). + +Live acceptance tests should be opt-in and credential-gated. Generic GA4GH +contract tests should run against every adapter so provider support cannot +silently diverge. + +## Compatibility and migration + +Introduce the unified form without immediately removing scripts: + +1. Add `remote add ` and store the compositional + remote configuration. +2. Keep `remote add gen3`, `local`, and `terra` as hidden or deprecated + compatibility shims that translate their arguments to the new model. +3. Add `git drs remote migrate` and make `remote list --verbose` display the + translated endpoint, provider, auth source (never its value), and + capabilities. +4. Warn on inline `--token`/`--password`, `--mode`, and `--no-skip-smudge` with + the exact replacement command for at least one release cycle. +5. Remove legacy forms only in a major release or retain them indefinitely as + thin aliases if maintenance cost is negligible. + +The minimal common case then becomes memorable: + +```sh +git drs remote add +``` + +Scope, authentication, publishing storage, and checkout policy appear only +when the server or workflow actually needs them. + +## References + +- [Cancer Genomics Cloud DRS API overview](https://docs.cancergenomicscloud.org/reference/drs-api-overview) +- [Synapse DRS controller REST documentation](https://rest-docs.synapse.org/rest/index.html#org.sagebionetworks.drs.controller.DrsController) From 57f85fe35e9b371fa696192280eb2c57e513592b Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 10:31:13 -0700 Subject: [PATCH 26/66] ignore working dirs --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b1aaab11..16da783b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage/unit/ admin/ # Ignore any file named .env anywhere in the repository .env +# ignore working dirs, files +attic/ +patch.txt From 7cb16424d16a2c0d24aaf9cce045124d30b5522c Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 10:54:20 -0700 Subject: [PATCH 27/66] clarified --auth and --credential --- docs/remote-cli-design.md | 68 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/remote-cli-design.md b/docs/remote-cli-design.md index 845343c6..6fd7b3a4 100644 --- a/docs/remote-cli-design.md +++ b/docs/remote-cli-design.md @@ -44,7 +44,7 @@ git drs remote add [flags] Flags: --scope Optional provider-specific collection/project scope - --auth auto, none, bearer, basic, google-adc, or provider-helper + --auth auto, none, bearer, basic, google-adc, or provider-helper[:name] --credential Credential source, never an inline secret --provider auto, ga4gh, gen3, terra, cgc, or synapse --storage Advanced write/registration storage override @@ -76,13 +76,75 @@ maintained presets; they are not separate protocols. `--provider` is an escape hatch when discovery is absent or ambiguous, and should normally remain `auto`. +### Authentication methods + +`--auth` selects **how an HTTP request is authorized**. It is distinct from +`--credential`, which selects **where the material needed by that method comes +from**. For example, `--auth bearer --credential env:CGC_TOKEN` says to put a +bearer token in the request and to read that token from `CGC_TOKEN`. + +The methods have the following meanings: + +| Method | Meaning | +| --- | --- | +| `auto` | Select a method from trusted configuration and discovery, using the deterministic order below. This is the default; it does not mean "try every credential." | +| `none` | Send no authentication. If the server rejects the request, report that authentication is required rather than silently trying local credentials. | +| `bearer` | Send an OAuth-style bearer access token obtained from `--credential`. | +| `basic` | Send an HTTP Basic username/password pair obtained from `--credential`. | +| `google-adc` | Use Google Application Default Credentials to obtain and refresh the token expected by the service. | +| `provider-helper` | Delegate token acquisition and refresh to the adapter for the selected provider; the adapter returns request credentials, but does not replace the DRS resolver. | + +`auto` should resolve once when the remote is added, persist the selected +method (not a secret), and show the result to the user. The selection order is: + +1. an explicit `--credential` whose type implies a method, if unambiguous; +2. the endpoint alias's maintained default (for example, a Terra alias may + select `google-adc`); +3. authentication metadata returned by trusted service discovery; +4. a single installed provider helper registered for the detected provider; +5. `none` only when an anonymous capability probe succeeds. + +If the evidence is absent, conflicting, or names multiple usable methods, +`remote add` should stop and ask the user to choose `--auth`; it must not send +available credentials to a server merely because they exist. A 401 challenge +may refine discovery, but it must not trigger a loop that tries bearer, basic, +and local credential stores in turn. Subsequent commands use the persisted +method, so `auto` does not make network behavior vary from invocation to +invocation. `git drs remote diagnose` may explicitly rerun discovery. + +`provider-helper` is for authentication lifecycles that the generic methods +cannot represent. Examples include a Gen3 profile helper that exchanges an API +key and refreshes a Fence access token, or a future Synapse helper that obtains +a short-lived token through Synapse's supported login flow. The value does not +mean "execute any program found on `PATH`": the helper must be registered by a +known provider adapter, selected by the detected or explicit `--provider`, and +subject to the endpoint allowlist for that remote. If more than one helper is +available, configuration should use a qualified value such as +`provider-helper:gen3-profile` rather than relying on search order. + +This differs from `--credential helper:synapse`: a **credential helper** only +returns already usable material, such as a bearer token, while a **provider +helper** owns a provider-specific exchange or refresh flow and may use a +credential source as its input. For example: + +```sh +# A generic bearer method reads an already usable token. +git drs remote add synapse https://repo-prod.prod.sagebase.org \ + --auth bearer --credential helper:synapse + +# A provider adapter exchanges the named Gen3 profile and refreshes tokens. +git drs remote add production https://example-gen3.org \ + --provider gen3 --auth provider-helper:gen3-profile \ + --credential profile:production --scope PROGRAM/PROJECT +``` + ### Defaults remove flags - Discover the provider and supported features using GA4GH service-info and a small, maintained fingerprint table. Print the detected provider before persisting configuration. -- Infer `--auth auto` from the preset, a 401 challenge, and installed credential - helpers. Do not silently downgrade from authenticated to anonymous access. +- Resolve `--auth auto` using the deterministic authentication selection above. + Do not silently downgrade from authenticated to anonymous access. - Infer read-only versus writable from capability probing. Replace `--mode` with reported capabilities rather than a user assertion. - Default checkout behavior from the repository's existing configuration. From a24b78a80b5e55e7b7096889706f776a2b8cb29e Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 11:52:46 -0700 Subject: [PATCH 28/66] Restrict override reads to repo-local config --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index ff7da793..f65bbbfe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -215,7 +215,7 @@ func parseAndAddRemote(cfg *Config, subsectionName string, remoteType string, en } func loadGitConfigOverrides(cfg *Config) error { - cmd := exec.Command("git", "config", "--get-regexp", `^drs\.`) + cmd := exec.Command("git", "config", "--local", "--get-regexp", `^drs\.`) out, err := cmd.Output() if err != nil { // git config exits non-zero when no matching keys exist. In that case, From 1259a5a3812ea0eaceba4ec82ead1b71a63862d2 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 12:01:38 -0700 Subject: [PATCH 29/66] Remove Terra auth and mode keys with the remote --- internal/config/config.go | 2 ++ internal/config/config_test.go | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index f65bbbfe..52922e22 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -375,6 +375,8 @@ func RemoveRemote(name Remote) (*Config, error) { fmt.Sprintf("drs.remote.%s.bucket", name), fmt.Sprintf("drs.remote.%s.organization", name), fmt.Sprintf("drs.remote.%s.storage_prefix", name), + fmt.Sprintf("drs.remote.%s.auth", name), + fmt.Sprintf("drs.remote.%s.mode", name), fmt.Sprintf("drs.remote.%s.token", name), fmt.Sprintf("drs.remote.%s.username", name), fmt.Sprintf("drs.remote.%s.password", name), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7b6ab109..33d8cf50 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -291,3 +291,38 @@ func TestUpdateRemote_LocalTypePersistence(t *testing.T) { t.Errorf("Expected BaseURL http://localhost:8080, got %s", localRemote.BaseURL) } } + +func TestRemoveRemote_TerraCleansAuthAndMode(t *testing.T) { + tmpDir := setupTestRepo(t) + remoteName := Remote("anvil") + + _, err := UpdateRemote(remoteName, RemoteSelect{ + Terra: &TerraRemote{ + Endpoint: "https://data.terra.bio", + Auth: "google-adc", + Mode: "read-only", + }, + }) + if err != nil { + t.Fatalf("UpdateRemote failed: %v", err) + } + + cfg, err := RemoveRemote(remoteName) + if err != nil { + t.Fatalf("RemoveRemote failed: %v", err) + } + if _, ok := cfg.Remotes[remoteName]; ok { + t.Fatalf("removed Terra remote %q was recreated", remoteName) + } + + for _, key := range []string{ + "drs.remote.anvil.auth", + "drs.remote.anvil.mode", + } { + cmd := exec.Command("git", "config", "--local", "--get", key) + cmd.Dir = tmpDir + if output, err := cmd.CombinedOutput(); err == nil { + t.Errorf("expected %s to be unset, got %q", key, string(output)) + } + } +} From 5f7aba1e8e1f47521bbeb4d8dd7db3071895a57a Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 12:08:25 -0700 Subject: [PATCH 30/66] Match the DRS access header schema --- internal/resolver/resolver.go | 14 ++++++++---- internal/resolver/resolver_test.go | 36 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index f06e6afc..7d356d88 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -57,8 +57,14 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string tmp.Close() return err } - for key, value := range access.Headers { - req.Header.Set(key, value) + for _, header := range access.Headers { + key, value, ok := strings.Cut(header, ":") + key = strings.TrimSpace(key) + if !ok || key == "" { + tmp.Close() + return fmt.Errorf("AnVIL resolver returned an invalid access header") + } + req.Header.Add(key, strings.TrimSpace(value)) } resp, err := http.DefaultClient.Do(req) if err != nil { @@ -110,8 +116,8 @@ type Checksum struct { } type ResolvedAccess struct { - URL string `json:"url"` - Headers map[string]string `json:"headers"` + URL string `json:"url"` + Headers []string `json:"headers"` } // AnVILResolver routes every DRS authority through the configured trusted diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 11eb3b73..b8ac9a33 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -3,8 +3,10 @@ package resolver import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" ) @@ -38,6 +40,40 @@ func TestAnVILResolverContract(t *testing.T) { } } +func TestDownloadToCacheUsesAccessURLHeaders(t *testing.T) { + const body = "data" + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Values("X-Provider-Token"); len(got) != 2 || got[0] != "first" || got[1] != "second:part" { + t.Errorf("unexpected provider headers: %q", got) + http.Error(w, "missing headers", http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(body)) + })) + defer download.Close() + + resolverServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ga4gh/drs/v1/objects/object-1": + _, _ = w.Write([]byte(`{"id":"object-1","size":4,"access_methods":[{"type":"https","access_id":"a1"}]}`)) + case "/ga4gh/drs/v1/objects/object-1/access/a1": + _, _ = fmt.Fprintf(w, `{"url":%q,"headers":["X-Provider-Token: first","X-Provider-Token: second:part"]}`, download.URL) + default: + http.NotFound(w, r) + } + })) + defer resolverServer.Close() + + r, err := NewAnVILWithClient(resolverServer.URL, resolverServer.Client()) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "cache", "object-1") + if err := DownloadToCache(context.Background(), r, "drs://example.org/object-1", destination); err != nil { + t.Fatalf("DownloadToCache returned error: %v", err) + } +} + func TestAnVILResolverAcceptsCompactDRSURI(t *testing.T) { const compactURI = "drs://drs.anv0:v2_e68887be-c583-375a-a773-48771192c8fa" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 2c63f35c75c2be09b32cafbc41523cae0ed83265 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 12:20:28 -0700 Subject: [PATCH 31/66] fix: Destination validation can escape through symlinks --- cmd/addref/add-ref.go | 22 ++++++++++++++++++ cmd/addref/add-ref_test.go | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index f181e473..86e271ed 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -141,6 +141,28 @@ func safeDestination(dst string) (string, error) { if err != nil { return "", err } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", fmt.Errorf("resolve repository root: %w", err) + } + + // Do not allow an existing path component to redirect the eventual pointer + // write. Checking only the cleaned path is insufficient: os.WriteFile and + // MkdirAll follow symlinks, including a symlink at the destination itself. + current := root + for _, component := range strings.Split(clean, string(filepath.Separator)) { + current = filepath.Join(current, component) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + break + } + if statErr != nil { + return "", fmt.Errorf("inspect destination path %s: %w", dst, statErr) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("destination path contains a symlink: %s", dst) + } + } return filepath.Join(root, clean), nil } diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index c18828c4..52c4e270 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -50,6 +50,53 @@ func TestCreateLfsPointer_NoSHA256(t *testing.T) { } } +func TestSafeDestinationRejectsSymlinkedParent(t *testing.T) { + repo := t.TempDir() + outside := t.TempDir() + runGitCmd(t, repo, "init") + if err := os.Symlink(outside, filepath.Join(repo, "data")); err != nil { + t.Fatal(err) + } + + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repo); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + if _, err := safeDestination(filepath.Join("data", "pointer")); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink destination to be rejected, got %v", err) + } +} + +func TestSafeDestinationRejectsSymlinkedFile(t *testing.T) { + repo := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + runGitCmd(t, repo, "init") + if err := os.WriteFile(outside, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(repo, "pointer")); err != nil { + t.Fatal(err) + } + + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repo); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + if _, err := safeDestination("pointer"); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink destination to be rejected, got %v", err) + } +} + func TestAddRefLocalOIDUsesDerivedSourceWhenSHA256Missing(t *testing.T) { obj := &drsapi.DrsObject{Checksums: []drsapi.Checksum{{Type: "md5", Checksum: "md5"}}} oid := addRefLocalOID("drs://example.org/object-1", "source", obj) From 7f76d076f5f4db996027868bb1ec756965247224 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 12:42:38 -0700 Subject: [PATCH 32/66] Fix Terra automatic smudge downloads --- cmd/filter/main.go | 21 ++++++++++-- cmd/filter/main_test.go | 71 +++++++++++++++++++++++++++++++++++++++++ cmd/smudge/main.go | 14 ++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 cmd/filter/main_test.go diff --git a/cmd/filter/main.go b/cmd/filter/main.go index cc70f45b..a60a6da8 100644 --- a/cmd/filter/main.go +++ b/cmd/filter/main.go @@ -23,6 +23,7 @@ import ( internalfilter "github.com/calypr/git-drs/internal/filter" "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/remoteruntime" + "github.com/calypr/git-drs/internal/resolver" internaltransfer "github.com/calypr/git-drs/internal/transfer" "github.com/spf13/cobra" ) @@ -52,6 +53,7 @@ func runFilter(cmd *cobra.Command, _ []string) error { } var drsCtx *remoteruntime.GitContext + var terraResolver resolver.Resolver remote, err := cfg.GetDefaultRemote() if err != nil { @@ -60,6 +62,11 @@ func runFilter(cmd *cobra.Command, _ []string) error { drsCtx, err = remoteruntime.New(cfg, remote, logger) if err != nil { logger.Info("DRS server not configured or unreachable", "err", err) + } else if drsCtx.RemoteType == config.TerraServerType && !internalfilter.ShouldSkipSmudge() { + terraResolver, err = resolver.NewAnVIL(ctx, drsCtx.Endpoint) + if err != nil { + return fmt.Errorf("filter: create Terra resolver: %w", err) + } } } @@ -70,7 +77,7 @@ func runFilter(cmd *cobra.Command, _ []string) error { logger.Debug("Resolved LFS root directory", "lfsRoot", lfsRoot) // Build the filter and register handlers. f := internalfilter.NewGitFilter(os.Stdin, os.Stdout, logger). - OnSmudge(makeSmudgeHandler(drsCtx, logger)). + OnSmudge(makeSmudgeHandler(drsCtx, terraResolver, logger)). OnClean(makeCleanHandler(lfsRoot, logger)) return f.Run(ctx) @@ -80,12 +87,15 @@ func runFilter(cmd *cobra.Command, _ []string) error { // Smudge handler — checkout: LFS pointer → real file content // -------------------------------------------------------------------------- -func makeSmudgeHandler(drsCtx *remoteruntime.GitContext, logger *slog.Logger) internalfilter.SmudgeFunc { +func makeSmudgeHandler(drsCtx *remoteruntime.GitContext, terraResolver resolver.Resolver, logger *slog.Logger) internalfilter.SmudgeFunc { return func(ctx context.Context, req internalfilter.FilterRequest, ptr io.Reader, dst io.Writer) error { logger.Debug("smudge handler invoked", "pathname", req.Pathname) var downloadFn internalfilter.SmudgeDownloadFunc if drsCtx != nil && !internalfilter.ShouldSkipSmudge() { downloadFn = func(callCtx context.Context, oid, cachePath string) error { + if terraResolver != nil { + return resolver.DownloadToCache(callCtx, terraResolver, normalizeDRSOID(oid), cachePath) + } return internaltransfer.DownloadToCachePath(callCtx, drsCtx, oid, cachePath) } } @@ -93,6 +103,13 @@ func makeSmudgeHandler(drsCtx *remoteruntime.GitContext, logger *slog.Logger) in } } +func normalizeDRSOID(oid string) string { + if len(oid) >= 2 && oid[:2] == "//" { + return "drs:" + oid + } + return oid +} + // -------------------------------------------------------------------------- // Clean handler — stage: real file content → LFS pointer // -------------------------------------------------------------------------- diff --git a/cmd/filter/main_test.go b/cmd/filter/main_test.go new file mode 100644 index 00000000..2cdccb72 --- /dev/null +++ b/cmd/filter/main_test.go @@ -0,0 +1,71 @@ +package filter + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + internalfilter "github.com/calypr/git-drs/internal/filter" + "github.com/calypr/git-drs/internal/remoteruntime" + "github.com/calypr/git-drs/internal/resolver" +) + +type recordingResolver struct { + accessURL string + drsURI string +} + +func (r *recordingResolver) GetObject(_ context.Context, drsURI string) (*resolver.ResolvedObject, error) { + r.drsURI = drsURI + return &resolver.ResolvedObject{ + Size: 13, + AccessMethods: []resolver.AccessMethod{{AccessID: "access-1"}}, + }, nil +} + +func (r *recordingResolver) GetAccess(context.Context, string, string) (*resolver.ResolvedAccess, error) { + return &resolver.ResolvedAccess{URL: r.accessURL}, nil +} + +func TestSmudgeHandlerUsesTerraResolver(t *testing.T) { + t.Setenv("GIT_DRS_SKIP_SMUDGE", "false") + payload := []byte("terra payload") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) + })) + defer server.Close() + + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".git", "lfs", "objects"), 0o755); err != nil { + t.Fatal(err) + } + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repo); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + r := &recordingResolver{accessURL: server.URL} + ctx := &remoteruntime.GitContext{} + handler := makeSmudgeHandler(ctx, r, slog.New(slog.NewTextHandler(io.Discard, nil))) + pointer := "version https://calypr.github.io/spec/v1\noid drs://example.org/object-1\nsize 13\n" + var output bytes.Buffer + if err := handler(context.Background(), internalfilter.FilterRequest{Pathname: "data.bin"}, bytes.NewBufferString(pointer), &output); err != nil { + t.Fatalf("smudge handler: %v", err) + } + if got, want := output.String(), string(payload); got != want { + t.Fatalf("smudged content = %q, want %q", got, want) + } + if got, want := r.drsURI, "drs://example.org/object-1"; got != want { + t.Fatalf("resolver DRS URI = %q, want %q", got, want) + } +} diff --git a/cmd/smudge/main.go b/cmd/smudge/main.go index faebd995..964df49c 100644 --- a/cmd/smudge/main.go +++ b/cmd/smudge/main.go @@ -10,6 +10,7 @@ import ( "github.com/calypr/git-drs/internal/drslog" internalfilter "github.com/calypr/git-drs/internal/filter" "github.com/calypr/git-drs/internal/remoteruntime" + "github.com/calypr/git-drs/internal/resolver" internaltransfer "github.com/calypr/git-drs/internal/transfer" "github.com/spf13/cobra" ) @@ -64,7 +65,20 @@ func runSmudge(cmd *cobra.Command, args []string) error { var downloadFn internalfilter.SmudgeDownloadFunc if !internalfilter.ShouldSkipSmudge() { + var terraResolver resolver.Resolver + if drsCtx.RemoteType == config.TerraServerType { + terraResolver, err = resolver.NewAnVIL(ctx, drsCtx.Endpoint) + if err != nil { + return fmt.Errorf("smudge: create Terra resolver: %w", err) + } + } downloadFn = func(callCtx context.Context, oid, cachePath string) error { + if terraResolver != nil { + if len(oid) >= 2 && oid[:2] == "//" { + oid = "drs:" + oid + } + return resolver.DownloadToCache(callCtx, terraResolver, oid, cachePath) + } return internaltransfer.DownloadToCachePath(callCtx, drsCtx, oid, cachePath) } } From 18ec625a01ea969160c3e9a9f3b42687115550a4 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 12:55:53 -0700 Subject: [PATCH 33/66] Clarify read-only Terra push behavior --- cmd/push/main.go | 7 ++++++- cmd/push/push_test.go | 23 +++++++++++++++++++++++ docs/anvil.md | 10 +++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/cmd/push/main.go b/cmd/push/main.go index 950a3090..e61aa8a3 100644 --- a/cmd/push/main.go +++ b/cmd/push/main.go @@ -59,7 +59,12 @@ var Cmd = &cobra.Command{ } } if selected, ok := cfg.Remotes[remote]; ok && selected.Terra != nil { - return fmt.Errorf("remote %q is read-only; publish AnVIL references with ordinary git push", remote) + return fmt.Errorf( + "remote %q is read-only: git drs push cannot upload files to Terra\n"+ + "no files were uploaded, and you do not need to back out a commit that references existing Terra data\n"+ + "to publish the commit and its DRS references, use ordinary git push to a Git remote; this pushes only Git metadata and does not upload files to Terra", + remote, + ) } drsClient, err := remoteruntime.New(cfg, remote, myLogger) diff --git a/cmd/push/push_test.go b/cmd/push/push_test.go index 69f16c4e..b8a79962 100644 --- a/cmd/push/push_test.go +++ b/cmd/push/push_test.go @@ -6,6 +6,7 @@ import ( "github.com/calypr/git-drs/internal/config" "github.com/calypr/git-drs/internal/testutils" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPushCmdArgs(t *testing.T) { @@ -38,3 +39,25 @@ func TestPushRun_DefaultRemoteError(t *testing.T) { err := Cmd.RunE(Cmd, []string{}) assert.Error(t, err) } + +func TestPushRun_ReadOnlyTerraRemoteExplainsWhatWasNotPushed(t *testing.T) { + tmpDir := testutils.SetupTestGitRepo(t) + testutils.CreateTestConfig(t, tmpDir, &config.Config{ + DefaultRemote: "anvil", + Remotes: map[config.Remote]config.RemoteSelect{ + "anvil": {Terra: &config.TerraRemote{ + Endpoint: "https://data.terra.bio", + Mode: "read-only", + }}, + }, + }) + + err := Cmd.RunE(Cmd, nil) + require.Error(t, err) + assert.ErrorContains(t, err, `remote "anvil" is read-only`) + assert.ErrorContains(t, err, "cannot upload files to Terra") + assert.ErrorContains(t, err, "no files were uploaded") + assert.ErrorContains(t, err, "do not need to back out a commit") + assert.ErrorContains(t, err, "ordinary git push to a Git remote") + assert.ErrorContains(t, err, "pushes only Git metadata") +} diff --git a/docs/anvil.md b/docs/anvil.md index c51f5217..74964a08 100644 --- a/docs/anvil.md +++ b/docs/anvil.md @@ -56,9 +56,13 @@ and writes a small pointer. The pointer retains the canonical DRS URI even when the record has a SHA256 checksum. It never contains an access token or signed download URL. Destination paths must stay inside the Git repository. -AnVIL remotes are read-only: publish pointers with ordinary `git push`. -`git drs push` refuses a Terra remote because that command uploads payloads to -writable DRS providers. +AnVIL remotes are read-only. `git drs push` refuses a Terra remote before doing +any remote work because that command uploads payloads to writable DRS +providers. The failure does not require backing out a commit that references +existing Terra data: publish that commit and its pointers with ordinary +`git push` to a Git remote. Plain `git push` transfers only the Git commit and +small DRS references; it does **not** upload the referenced files to Terra or +create or modify Terra DRS records. ## Clone and hydrate as another user From acb8601aeef90414755395299be2cbb51c95cdb4 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 13:10:19 -0700 Subject: [PATCH 34/66] docs DRS compact identifiers and auth discovery --- docs/compact-identifier-auth-discovery.md | 140 ++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/compact-identifier-auth-discovery.md diff --git a/docs/compact-identifier-auth-discovery.md b/docs/compact-identifier-auth-discovery.md new file mode 100644 index 00000000..ad4e21d6 --- /dev/null +++ b/docs/compact-identifier-auth-discovery.md @@ -0,0 +1,140 @@ +# DRS compact identifiers, remote routing, and authentication discovery + +## Summary + +`git-drs` currently treats a DRS URI as an object identifier, not as a +replacement for remote configuration. Remote configuration continues to store +a concrete HTTP endpoint and provider-specific settings. + +The authority in a `drs://` URI participates in resolution in some code paths, +but it is not universally treated as a directly reachable HTTPS hostname. This +distinction is important because a DRS authority can be an identifier namespace +rather than a DNS name. + +The GA4GH DRS service-info operation can be used as one input to service and +authentication discovery. `git-drs` currently probes service-info for Terra +health checks, but it does not yet infer or persist an authentication method +from the response. + +## Does a DRS compact identifier replace the configured remote? + +No. There are two separate concepts: + +- A DRS URI or compact identifier, such as `drs://drs.anv0:v2_...`, identifies + a DRS object and its authority. +- A configured `git-drs` remote contains the concrete HTTP endpoint and any + provider-specific configuration needed to contact the service. + +The current configuration model stores an endpoint or base URL for Gen3, +Terra, and local remotes. These values are persisted as +`drs.remote..endpoint` in repository configuration. + +The proposed unified remote interface may accept maintained aliases such as +`terra`, `cgc`, and `synapse`. These aliases would expand to concrete, +versioned endpoint presets; they are conveniences, not DRS object identifiers +or separate protocols. + +The local compact OID described in the DRS identity ADR is another distinct +concept. The DRS URI is the canonical remote object identity, while the derived +OID is only a compact, filesystem-safe key for pointers and cache storage. The +derived OID does not identify or configure the DRS server. + +## Is the `drs://` authority resolved as a hostname? + +Partially, depending on the URI form and resolution path. + +### Slash-form URI + +For a URI such as: + +```text +drs://source.example.org/object-1 +``` + +the `add-ref` resolution path: + +1. parses the URI authority; +2. looks for a configured remote whose endpoint host matches that authority; +3. uses that configured remote, including its authentication, when one matches; +4. otherwise derives `https://source.example.org` and attempts an anonymous + DRS object lookup. + +This fallback allows a complete slash-form DRS URI to direct resolution to its +source service without incorrectly sending it to the selected primary remote. + +### Compact colon-form URI + +For a compact identifier such as: + +```text +drs://drs.anv0:v2_example +``` + +the AnVIL resolver understands that the colon separates the authority from the +object ID; it is not a network port separator. It extracts `drs.anv0` as the +authority and `v2_example` as the object ID. + +The AnVIL resolver does not convert `drs.anv0` into an HTTPS endpoint. Instead, +it routes the identifier through the configured, trusted Terra/AnVIL resolver +endpoint. In this case the DRS authority is a namespace used by the resolver, +not necessarily a directly reachable DNS host. + +The direct `add-ref` source-host parser currently expects an object ID in the +URI path. Compact colon-form identifiers therefore do not use the same +authority-derived anonymous fallback as slash-form identifiers; they depend on +the provider-specific resolver path. + +### Recommended routing rule + +The authority should participate in routing, but `git-drs` should prefer a +trusted authority-to-endpoint mapping or a configured resolver. It should not +universally assume that every DRS authority can safely be contacted as +`https://`. + +## Can service-info determine how authentication works? + +The GA4GH DRS service-info operation is available at the conventional route: + +```http +GET /ga4gh/drs/v1/service-info +``` + +For Terra remotes, `git drs ping` already sends this request, treats a +successful response as a health check, and prints the service-info response. +The request currently uses the default HTTP client. + +`git-drs` does not currently use that response to: + +- select bearer, Basic, Google ADC, or provider-helper authentication; +- identify a credential source; +- persist a discovered authentication method; +- discover authentication for every remote type. + +Service-info can be one trusted discovery input, but it may not contain enough +information to configure authentication completely. A response might identify +the service without specifying all of the OAuth issuer, audience, scopes, +credential source, or provider-specific login workflow required by a client. + +The proposed `--auth auto` behavior therefore uses a deterministic precedence: + +1. an explicit credential whose type implies one authentication method; +2. the maintained default from a trusted endpoint alias; +3. authentication metadata returned by trusted service discovery; +4. one installed provider helper for the detected provider; +5. anonymous access only after an anonymous capability probe succeeds. + +If discovery is absent, conflicting, or advertises multiple usable methods, +remote setup should require an explicit authentication choice. It must not try +each locally available credential against the server. Once selected, the +authentication method should be persisted so normal commands have stable +network behavior; an explicit diagnostic command may rerun discovery. + +## Current behavior at a glance + +| Question | Current behavior | +| --- | --- | +| Does a DRS compact identifier replace the configured endpoint? | No. Remote endpoints remain in configuration. | +| Is the `drs://` authority used for routing? | Partially. Slash-form external references can derive an HTTPS endpoint; AnVIL compact identifiers use the configured trusted resolver. | +| Can `git-drs` call service-info? | Yes. `git drs ping` calls it for Terra remotes. | +| Does service-info automatically configure authentication? | No. Authentication discovery is proposed but not implemented. | +| Should every authority be treated as an HTTPS hostname? | No. Prefer a trusted mapping or resolver because an authority may be a namespace rather than a reachable host. | From 9553fec50dda35866615037f4944237164cf7ef9 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 13:56:21 -0700 Subject: [PATCH 35/66] Make pulled files read-only for read-only remotes --- cmd/pull/main.go | 29 ++++++++++++++++++-- cmd/pull/pull_test.go | 62 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/cmd/pull/main.go b/cmd/pull/main.go index 27efa844..c2e01c0b 100644 --- a/cmd/pull/main.go +++ b/cmd/pull/main.go @@ -218,7 +218,8 @@ var Cmd = &cobra.Command{ logg.Debug("no missing pointer objects to download") } - if err := checkoutDownloadedFiles(pointers, progress); err != nil { + readOnly := remoteIsReadOnly(cfg, remote) + if err := checkoutDownloadedFiles(pointers, progress, readOnly); err != nil { return err } if err := refreshGitIndexForHydratedFiles(pointers); err != nil { @@ -439,7 +440,15 @@ func globToRegexp(pattern string) string { return b.String() } -func checkoutDownloadedFiles(files []pointerFile, progress *internaltransfer.PullProgressRenderer) error { +func remoteIsReadOnly(cfg *config.Config, remote config.Remote) bool { + selected, ok := cfg.Remotes[remote] + // Terra remotes are resolver-only and therefore always read-only. Match the + // push command's capability check rather than relying on optional legacy + // configuration fields being present. + return ok && selected.Terra != nil +} + +func checkoutDownloadedFiles(files []pointerFile, progress *internaltransfer.PullProgressRenderer, readOnly bool) error { for _, f := range files { if strings.TrimSpace(f.Name) == "" || strings.TrimSpace(f.Oid) == "" { continue @@ -462,6 +471,17 @@ func checkoutDownloadedFiles(files []pointerFile, progress *internaltransfer.Pul return fmt.Errorf("failed to create directory for %s: %w", f.Name, err) } } + // A previous pull may have made this path read-only. Temporarily restore + // owner write permission so that a later pull can safely replace it. + if info, statErr := os.Stat(f.Name); statErr == nil { + if err := os.Chmod(f.Name, info.Mode().Perm()|0o200); err != nil { + src.Close() + return fmt.Errorf("failed to make %s writable for checkout: %w", f.Name, err) + } + } else if !os.IsNotExist(statErr) { + src.Close() + return fmt.Errorf("failed to inspect checkout path %s: %w", f.Name, statErr) + } dst, err := os.OpenFile(f.Name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { src.Close() @@ -485,6 +505,11 @@ func checkoutDownloadedFiles(files []pointerFile, progress *internaltransfer.Pul } return fmt.Errorf("checked out invalid content for %s: %w", f.Name, err) } + if readOnly { + if err := os.Chmod(f.Name, 0o444); err != nil { + return fmt.Errorf("failed to make pulled file %s read-only: %w", f.Name, err) + } + } progress.OnCompleted(toPullFile(f)) } return nil diff --git a/cmd/pull/pull_test.go b/cmd/pull/pull_test.go index c6c7fe49..eeab5df6 100644 --- a/cmd/pull/pull_test.go +++ b/cmd/pull/pull_test.go @@ -250,7 +250,7 @@ func TestCheckoutDownloadedFilesRejectsInvalidCachedObject(t *testing.T) { files := []pointerFile{{Name: "data/file.bin", Oid: oid, Size: 100}} progress.OnPlan(toPullFiles(files)) - err = checkoutDownloadedFiles(files, progress) + err = checkoutDownloadedFiles(files, progress, false) if err == nil { t.Fatal("expected checkoutDownloadedFiles to reject invalid cached object") } @@ -293,7 +293,7 @@ func TestCheckedOutContentCleansBackToPointer(t *testing.T) { progress := internaltransfer.NewPullProgressRenderer(io.Discard) files := []pointerFile{{Name: "data/file.bin", Oid: oid, Size: int64(len(payload))}} progress.OnPlan(toPullFiles(files)) - if err := checkoutDownloadedFiles(files, progress); err != nil { + if err := checkoutDownloadedFiles(files, progress, false); err != nil { t.Fatalf("checkoutDownloadedFiles: %v", err) } @@ -316,6 +316,64 @@ func TestCheckedOutContentCleansBackToPointer(t *testing.T) { } } +func TestCheckoutDownloadedFilesFromReadOnlyRemoteSetsReadOnlyPermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not supported on Windows") + } + + repo := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir repo: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + payload := []byte("read-only remote payload") + sum := sha256.Sum256(payload) + oid := hex.EncodeToString(sum[:]) + cachePath, err := lfs.ObjectPath(gitrepo.LFSObjectsPath, oid) + if err != nil { + t.Fatalf("ObjectPath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + t.Fatalf("mkdir cache dir: %v", err) + } + if err := os.WriteFile(cachePath, payload, 0o644); err != nil { + t.Fatalf("write cache: %v", err) + } + + files := []pointerFile{{Name: "data/file.bin", Oid: oid, Size: int64(len(payload))}} + progress := internaltransfer.NewPullProgressRenderer(io.Discard) + progress.OnPlan(toPullFiles(files)) + if err := checkoutDownloadedFiles(files, progress, true); err != nil { + t.Fatalf("checkoutDownloadedFiles: %v", err) + } + + info, err := os.Stat(files[0].Name) + if err != nil { + t.Fatalf("stat checked-out file: %v", err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o444); got != want { + t.Fatalf("checked-out permissions = %o, want %o", got, want) + } +} + +func TestRemoteIsReadOnly(t *testing.T) { + cfg := &config.Config{Remotes: map[config.Remote]config.RemoteSelect{ + "anvil": {Terra: &config.TerraRemote{Mode: "read-only"}}, + "local": {Local: &config.LocalRemote{}}, + }} + if !remoteIsReadOnly(cfg, "anvil") { + t.Fatal("expected read-only Terra remote to be detected") + } + if remoteIsReadOnly(cfg, "local") { + t.Fatal("did not expect local remote to be read-only") + } +} + func TestRefreshGitIndexForHydratedFilesClearsDirtyStatus(t *testing.T) { repo := t.TempDir() gitDRS := buildGitDRSBinaryForTest(t) From d4b7bb789671e59aff155dc0287a07f8ea37cccd Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 17:12:47 -0700 Subject: [PATCH 36/66] Document rollback for accidental read-only DRS edits --- docs/anvil.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/anvil.md b/docs/anvil.md index 74964a08..36a30f44 100644 --- a/docs/anvil.md +++ b/docs/anvil.md @@ -85,6 +85,53 @@ Cloning the Git repository does **not** grant AnVIL data access. A user without permission can inspect reference paths and DRS URIs but receives an authorization error when hydration resolves the object. +## Undo an accidental change to downloaded data + +`git drs pull` makes files downloaded from a read-only remote read-only. If a +user nevertheless makes a downloaded file writable, changes it, stages it, and +commits it, the clean filter records the changed content as a new pointer. +`git drs push` will still refuse the operation because the Terra remote is +read-only; that error does not by itself identify the accidental edit. + +If the mistaken commit is the latest commit and has not been pushed, restore +the original DRS pointer from its parent and amend the commit: + +```bash +# Inspect the change before rewriting the commit. +git diff HEAD^ HEAD -- data/sample.cram + +# Restore the original pointer in both the index and working tree. +git restore --source=HEAD^ --staged --worktree -- data/sample.cram +git commit --amend --no-edit + +# Confirm that the committed file is a pointer again. +git show HEAD:data/sample.cram +git status +``` + +If the entire latest commit was a mistake and it contains no work that should +be kept, it can instead be removed with `git reset --hard HEAD^`. Inspect the +commit and working tree first because this discards all changes in both. + +If the mistaken commit has already been shared, do not rewrite shared history. +Restore the pointer in a corrective commit: + +```bash +git restore --source=HEAD^ --staged --worktree -- data/sample.cram +git commit -m "Restore AnVIL DRS reference" +git push +``` + +After either repair, hydrate the restored pointer again when local access to +the payload is needed: + +```bash +git drs pull -I "data/sample.cram" +``` + +Use ordinary `git push` to publish commits containing read-only AnVIL +references. Do not use `git drs push` for a Terra remote. + ## Security and troubleshooting * `google application default credentials are unavailable`: run From 1a2850ceec6ae8323e4332887696456f9af8c371 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 21 Jul 2026 17:34:16 -0700 Subject: [PATCH 37/66] Refresh AnVIL POC gaps and roadmap --- docs/anvil-terra-poc-presentation.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/anvil-terra-poc-presentation.html b/docs/anvil-terra-poc-presentation.html index b3784dcd..d020e235 100644 --- a/docs/anvil-terra-poc-presentation.html +++ b/docs/anvil-terra-poc-presentation.html @@ -201,7 +201,7 @@

AnVIL + Terra
meets git-drs

Implementation audit

Most original blockers are now closed

✓ Implemented

  • ADC-backed AnVILResolver handles metadata and access.
  • Terra add-ref and pull use provider-neutral resolution.
  • Remote config selects behavior; --remote-type is hidden and deprecated.
  • Terra pointers retain DRS URI, size, and optional SHA256.
  • DRS cache keys are derived separately from content checksums.
-

△ Still incomplete

  • Provider-neutral routing is not yet universal; legacy providers still use Syfon paths.
  • The production AnVIL endpoint/scopes/contract still require authoritative validation.
  • Manifest resolution is sequential; bounded concurrency is not implemented.
  • Downloads lack bounded retry and automatic expired-URL re-resolution.
  • No complete clean-clone, independent two-user acceptance test exists.
+

△ Still incomplete

  • Routing is only partly provider-neutral: slash-form authorities may map to HTTPS, while compact AnVIL IDs still require the trusted Terra resolver.
  • Service-info is only a Terra health probe; it does not discover or persist authentication, provider, or capabilities.
  • Remote setup still has provider-specific command shapes instead of one endpoint/alias + scope/auth model.
  • Downloads still lack bounded concurrency, retry, cancellation, and expired-URL re-resolution.
  • The production contract and a clean-clone, independent two-user acceptance test remain unverified.

Bottom line: this is no longer an unwired design. It is an implemented vertical slice that still needs production-contract validation, resilience, and end-to-end proof.

@@ -209,9 +209,9 @@

AnVIL + Terra
meets git-drs

Next steps

Turn the vertical slice into a proven POC

-
P0 · VERIFY

Prove production fit

  • Confirm endpoint + OAuth scopes
  • Certify resolver response contract
  • Choose stable test records
  • Run clean two-user clone/pull
  • Audit logs/history for secrets
-
P1 · HARDEN

Make it resilient

  • Expired-URL re-resolution
  • Bounded retry + cancellation
  • Bounded manifest concurrency
  • git drs doctor
  • CI + compatibility matrix
-
P2 · DEFER

Protect the scope

  • AnVIL uploads or mutation
  • Cross-provider copying
  • Remote GC
  • Workspace sync
  • All-provider redesign
+
P0 · VERIFY

Prove production fit

  • Confirm endpoint + OAuth scopes
  • Certify service-info/object/access contracts
  • Test slash + compact DRS IDs
  • Run clean two-user clone/pull
  • Audit logs/history for secrets
+
P1 · HARDEN

Make it resilient

  • Trusted authority/alias routing
  • Persist deterministic auth discovery
  • Expired-URL re-resolution
  • Bounded retry + concurrency
  • Remote diagnostics + CI
+
P2 · GENERALIZE

Keep DRS composable

  • Unify remote add
  • Resolver/auth/capability interfaces
  • CGC + Synapse contract fixtures
  • Keep publishing provider-specific
  • Defer mutation and cross-provider copy
From fdd0065e75ca50ce3d23e63280b3f6a0d73b7d09 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 22 Jul 2026 11:21:40 -0700 Subject: [PATCH 38/66] Publish end-user docs and presentation with GitHub Pages --- .github/workflows/pages.yaml | 73 ++++++ README.md | 2 + docs/anvil-terra-poc-presentation.html | 237 ----------------- docs/anvil-terra-poc-presentation.md | 345 +++++++++++++++++++++++++ docs/github-pages.md | 98 +++++++ docs/index.html | 109 -------- docs/index.md | 35 +++ mkdocs.yml | 30 +++ 8 files changed, 583 insertions(+), 346 deletions(-) create mode 100644 .github/workflows/pages.yaml delete mode 100644 docs/anvil-terra-poc-presentation.html create mode 100644 docs/anvil-terra-poc-presentation.md create mode 100644 docs/github-pages.md delete mode 100644 docs/index.html create mode 100644 docs/index.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml new file mode 100644 index 00000000..0d7c915d --- /dev/null +++ b/.github/workflows/pages.yaml @@ -0,0 +1,73 @@ +name: Publish documentation + +on: + push: + branches: [main] + paths: + - docs/** + - mkdocs.yml + - .github/workflows/pages.yaml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install documentation builder + run: python -m pip install mkdocs-material==9.6.14 + + - name: Select end-user documentation + run: | + mkdir -p .pages-docs + cp docs/index.md .pages-docs/ + cp docs/quickstart.md .pages-docs/ + cp docs/getting-started.md .pages-docs/ + cp docs/commands.md .pages-docs/ + cp docs/pointer-files.md .pages-docs/ + cp docs/adding-s3-files.md .pages-docs/ + cp docs/remove-files.md .pages-docs/ + cp docs/troubleshooting.md .pages-docs/ + + - name: Build documentation and presentation + run: | + npx --yes @marp-team/marp-cli@4.2.3 \ + docs/anvil-terra-poc-presentation.md \ + --html \ + --output .pages-docs/anvil-terra-poc-presentation.html + mkdocs build --strict + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index eb3e5d91..65c2ec7b 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,12 @@ Push and pull depend on server-side bucket mapping for the requested scope. That ## Documentation +- [Documentation Home](docs/index.md) - [Getting Started](docs/getting-started.md) - [Commands Reference](docs/commands.md) - [Troubleshooting](docs/troubleshooting.md) - [Developer Guide](docs/developer-guide.md) +- [Publishing documentation with GitHub Pages](docs/github-pages.md) - [GA4GH DRS Scalability Gaps](docs/ga4gh-drs-scalability-gaps.md) ## Requirements diff --git a/docs/anvil-terra-poc-presentation.html b/docs/anvil-terra-poc-presentation.html deleted file mode 100644 index d020e235..00000000 --- a/docs/anvil-terra-poc-presentation.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - AnVIL + Terra × git-drs — reference POC - - - -
-
-
-

Reference proof of concept

-

AnVIL + Terra
meets git-drs

-

Version controlled dataset layouts—without copying controlled-access payloads into Git.

- -
- -
-

The idea in one picture

Git moves references. AnVIL moves bytes.

-
-
USER AAdd referencesADC-authorized metadata validation
-
GITCommit pointersCanonical DRS URI + size + checksum
-
USER BClone + authenticateIndependent Google identity
-
ANVILHydrateFresh authorized URL; verified bytes
-
-
0
payload bytes committed to Git
2
independent user identities
1
portable canonical reference
-

Security boundary: Git visibility reveals paths and DRS identifiers; it never grants authorization to the underlying data.

-
- -
-

Use cases

One reference model, four perspectives

-
-

✦ Data reference author

Publish a reviewable, versioned dataset layout—one object or a manifest—without downloading payloads.

-

↓ Data consumer

Clone pointers, authenticate independently, then hydrate everything or only paths matching a pattern.

-

◉ Unauthorized reader

Inspect repository history but receive a clear denial when attempting controlled-data hydration.

-

✓ Repository maintainer

Review deterministic pointers, diagnose provider failures, and run clean-clone acceptance tests.

-
- -
- -
-

How to · author

Add once. Publish with ordinary Git.

-
-
gcloud auth application-default login - -git drs add-ref --remote anvil \ - drs://authority/object-1 data/sample.cram - -# or: validate a batch before writing -git drs add-ref --remote anvil \ - --manifest references.tsv --dry-run - -git add .git-drs/ .gitattributes data/ -git commit -m "Add AnVIL data references" -git push
-

What gets published

  • Canonical DRS URI pointers
  • Paths and Git history
  • .gitattributes
  • Allowlisted, non-secret remote configuration

What never gets published

  • Payload bytes or cache content
  • Tokens, headers, or ADC files
  • Signed download URLs
-
-
- -
-

How to · consumer

A clean clone is enough

-
-
gcloud auth application-default login - -git clone <git-repository> -cd <repository> - -# hydrate every authorized reference -git drs pull - -# or only one dataset slice -git drs pull -I "data/*.cram"

No author cache, local Git config, token, or signed URL is transferred.

-
GIT
Clone pointer + safe public config
USER B
Supply ADC; choose all or include pattern
RESOLVER
Fetch current metadata and fresh access
CACHE
Download → verify → atomic promote
WORKTREE
Hydrate destination only after validation
-
-
- -
-

Example user workflow

From AnVIL manifest to a pointer-only GitHub repo

-
-

1 · Configure and add references

git init - -# Configure the read-only Terra remote. -git drs remote add terra anvil \ - --drs-endpoint https://data.terra.bio \ - --auth google-adc \ - --mode read-only - -# Download the TSV from AnVIL Data Explorer first. -manifest=/tmp/anvil-manifest-38dc7537.tsv - -# Generate, review, then run one add-ref per row. -scripts/anvil-add-ref-commands.sh "$manifest" \ - > /tmp/add-anvil-refs.sh -cat /tmp/add-anvil-refs.sh -bash /tmp/add-anvil-refs.sh
-

2 · Commit, hydrate, and publish

# The generated commands create references such as: -# sequencing_dataset.tsv · subject.tsv -# sequencing_file.tsv · plink_file_wide.tsv -# sample.tsv · sample_set.tsv -# population_descriptor.tsv - -git add .git-drs/ .gitattributes '*.tsv' -git commit -m "Add references to AnVIL data" - -# Materialize only TSVs on local disk. -git drs pull -I "*.tsv" -git status - -git branch -M main -git remote add origin \ - https://github.com/bwalsh/ANVIL_1000G_PRIMED_data_model.git -git push -u origin main
-
-

Result: the local worktree contains hydrated TSV data; GitHub contains only small DRS pointers and safe public configuration—not the dataset payloads.

-
- -
-

Examples

Portable pointer + safe configuration

-
-

Tracked pointer

version https://calypr.github.io/spec/v1 -oid drs://authority/object-1 -size 987654321 -sha256 8d969eef…

The DRS URI stays canonical even when a SHA256 is available. A checksum describes content; it does not identify the AnVIL record.

-

Tracked .git-drs/config.yaml

version: 1 -default_remote: anvil -remotes: - anvil: - type: terra - endpoint: https://data.terra.bio - auth: google-adc - mode: read-only

Only allowlisted public settings load. Secret-like or unknown sensitive fields fail closed.

-
-
- -
-

Architecture

Provider-neutral resolution breaks Syfon coupling

- - - Commandsadd-ref · pull · ping - Resolver interfaceGetObject · GetAccess - Identityremote · cache · content - - AnVILResolverGoogle ADC · routing - SyfonResolverGen3 · local behavior - - -

Resolver selection comes from the configured remote—not a user-facing --remote-type switch.

-
- -
-

Architecture detail

Three identities—not one overloaded OID

-
-
REMOTE

Canonical DRS URI

Normalized provider record identity. Committed to Git and used for object/access resolution.

-
LOCAL

Cache OID

Filesystem-safe SHA256 of a versioned prefix plus normalized DRS URI. Never replaces remote identity.

-
CONTENT

Content SHA256

Durable integrity metadata, when supplied. Used to verify bytes—not to locate the record.

-
-
cache_oid = sha256("git-drs-anvil-ref:v1\n" + normalized_drs_uri)
-

Download invariant: resolve fresh access → temporary file → size/checksum verify → atomic cache promotion → worktree hydration.

-
- -
-

Tests

Good component coverage; one decisive gap

-
-
IMPLEMENTED

Focused coverage

  • Resolver contract + errors
  • Terra remote + safe config
  • Canonical pointer/cache key
  • Terra ping + push refusal
-
PARTIAL

Workflow coverage

  • Manifest validation + dry run
  • Pull size/SHA256 checks
  • Selective hydration
  • Cache reuse behavior
-
MISSING

POC acceptance

  • Independent User A/User B state
  • Real or contract-faithful AnVIL
  • Expired URL + retry journey
  • Authorization leak audit
-
-
ArrangeSeparate homes, config, ADC, caches
ActCommit → clone → authenticate → pull
AssertVerified bytes; isolated credentials
-
- -
-

Implementation audit

Most original blockers are now closed

-
-

✓ Implemented

  • ADC-backed AnVILResolver handles metadata and access.
  • Terra add-ref and pull use provider-neutral resolution.
  • Remote config selects behavior; --remote-type is hidden and deprecated.
  • Terra pointers retain DRS URI, size, and optional SHA256.
  • DRS cache keys are derived separately from content checksums.
-

△ Still incomplete

  • Routing is only partly provider-neutral: slash-form authorities may map to HTTPS, while compact AnVIL IDs still require the trusted Terra resolver.
  • Service-info is only a Terra health probe; it does not discover or persist authentication, provider, or capabilities.
  • Remote setup still has provider-specific command shapes instead of one endpoint/alias + scope/auth model.
  • Downloads still lack bounded concurrency, retry, cancellation, and expired-URL re-resolution.
  • The production contract and a clean-clone, independent two-user acceptance test remain unverified.
-
-

Bottom line: this is no longer an unwired design. It is an implemented vertical slice that still needs production-contract validation, resilience, and end-to-end proof.

-
- -
-

Next steps

Turn the vertical slice into a proven POC

-
-
P0 · VERIFY

Prove production fit

  • Confirm endpoint + OAuth scopes
  • Certify service-info/object/access contracts
  • Test slash + compact DRS IDs
  • Run clean two-user clone/pull
  • Audit logs/history for secrets
-
P1 · HARDEN

Make it resilient

  • Trusted authority/alias routing
  • Persist deterministic auth discovery
  • Expired-URL re-resolution
  • Bounded retry + concurrency
  • Remote diagnostics + CI
-
P2 · GENERALIZE

Keep DRS composable

  • Unify remote add
  • Resolver/auth/capability interfaces
  • CGC + Synapse contract fixtures
  • Keep publishing provider-specific
  • Defer mutation and cross-provider copy
-
-
- -
-

Questions

Decisions needed to start the POC

-
01 · Which authoritative AnVIL resolver contract, endpoint, and OAuth scopes will we certify?
-
02 · What stable test objects cover checksummed, non-checksummed, large, and denied cases?
-
03 · Is the proposed pointer extension compatible with every parser that must read it?
-
04 · What is the supported override policy for tracked vs. local remote configuration?
-
05 · Who owns the independent-user acceptance environment and compatibility matrix?
- -
-
- - - - diff --git a/docs/anvil-terra-poc-presentation.md b/docs/anvil-terra-poc-presentation.md new file mode 100644 index 00000000..3de78a23 --- /dev/null +++ b/docs/anvil-terra-poc-presentation.md @@ -0,0 +1,345 @@ +--- +marp: true +title: AnVIL + Terra x git-drs — reference POC +description: A reference proof of concept for version-controlled dataset layouts +paginate: true +theme: git-drs +--- + + + + + + +# AnVIL + Terra meets **git-drs** + +Version-controlled dataset layouts—without copying controlled-access payloads into Git. + +Reference proof of concept · July 2026 + +--- + +## Git moves references. AnVIL moves bytes. + +| User A | Git | User B | AnVIL | +|---|---|---|---| +| Add references with ADC-authorized metadata validation | Commit canonical DRS URI, size, and checksum | Clone and authenticate with an independent Google identity | Hydrate from a fresh authorized URL and verify bytes | + +
+

0 payload bytes

committed to Git

+

2 identities

authenticate independently

+

1 reference

stays canonical and portable

+
+ +> **Security boundary:** Git visibility reveals paths and DRS identifiers; it never grants authorization to the underlying data. + +--- + +## One reference model, four perspectives + +
+

Data reference author

Publish a reviewable, versioned dataset layout—one object or a manifest—without downloading payloads.

+

Data consumer

Clone pointers, authenticate independently, then hydrate everything or only paths matching a pattern.

+

Unauthorized reader

Inspect repository history but receive a clear denial when attempting controlled-data hydration.

+

Repository maintainer

Review deterministic pointers, diagnose provider failures, and run clean-clone acceptance tests.

+
+ +**Out of scope:** uploads, record mutation, provider copying, remote GC, and workspace-table sync. + +--- + +## Add once. Publish with ordinary Git. + +
+
+ +```bash +gcloud auth application-default login + +git drs add-ref --remote anvil \ + drs://authority/object-1 data/sample.cram + +# Validate a batch before writing +git drs add-ref --remote anvil \ + --manifest references.tsv --dry-run + +git add .git-drs/ .gitattributes data/ +git commit -m "Add AnVIL data references" +git push +``` +
+
+ +### Published + +- Canonical DRS URI pointers +- Paths and Git history +- `.gitattributes` +- Allowlisted, non-secret remote configuration + +### Never published + +- Payload bytes or cache content +- Tokens, headers, or ADC files +- Signed download URLs +
+
+ +--- + +## A clean clone is enough + +
+
+ +```bash +gcloud auth application-default login + +git clone +cd + +# Hydrate every authorized reference +git drs pull + +# Or only one dataset slice +git drs pull -I "data/*.cram" +``` + +> No author cache, local Git config, token, or signed URL is transferred. +
+
+ +| Stage | Action | +|---|---| +| **Git** | Clone pointer and safe public config | +| **User B** | Supply ADC; choose all or include pattern | +| **Resolver** | Fetch current metadata and fresh access | +| **Cache** | Download, verify, atomically promote | +| **Worktree** | Hydrate only after validation | +
+
+ +--- + + + +## AnVIL manifest to pointer-only GitHub repo + +
+
+ +### 1. Configure and add references + +```bash +git init +git drs remote add terra anvil \ + --drs-endpoint https://data.terra.bio \ + --auth google-adc --mode read-only + +# Download the TSV from AnVIL Data Explorer first. +manifest=/tmp/anvil-manifest-38dc7537.tsv +scripts/anvil-add-ref-commands.sh "$manifest" \ + > /tmp/add-anvil-refs.sh +cat /tmp/add-anvil-refs.sh +bash /tmp/add-anvil-refs.sh +``` +
+
+ +### 2. Commit, hydrate, and publish + +```bash +git add .git-drs/ .gitattributes '*.tsv' +git commit -m "Add references to AnVIL data" + +# Materialize only TSVs locally. +git drs pull -I "*.tsv" +git status + +git branch -M main +git remote add origin \ + https://github.com/bwalsh/\ +ANVIL_1000G_PRIMED_data_model.git +git push -u origin main +``` +
+
+ +> **Result:** the worktree contains hydrated TSV data; GitHub contains only DRS pointers and safe public configuration. + +--- + +## Portable pointer + safe configuration + +
+
+ +### Tracked pointer + +```text +version https://calypr.github.io/spec/v1 +oid drs://authority/object-1 +size 987654321 +sha256 8d969eef… +``` + +The DRS URI stays canonical. A checksum describes content; it does not identify the AnVIL record. +
+
+ +### Tracked `.git-drs/config.yaml` + +```yaml +version: 1 +default_remote: anvil +remotes: + anvil: + type: terra + endpoint: https://data.terra.bio + auth: google-adc + mode: read-only +``` + +Only allowlisted public settings load. Secret-like or unknown sensitive fields fail closed. +
+
+ +--- + +## Provider-neutral resolution breaks Syfon coupling + +```text +Commands Resolver interface Identities +add-ref · pull · ping -> GetObject · GetAccess -> remote · cache · content + | + +----------+----------+ + | | + AnVILResolver SyfonResolver + Google ADC · routing Gen3 · local behavior +``` + +### Design rules + +- Commands depend on one resolver contract. +- The configured remote selects the resolver. +- Authentication and provider routing stay behind the interface. +- There is no user-facing `--remote-type` switch. + +--- + +## Three identities—not one overloaded OID + +
+
REMOTE

Canonical DRS URI

Normalized provider record identity. Committed to Git and used for object and access resolution.

+
LOCAL

Cache OID

Filesystem-safe SHA256 of a versioned prefix plus normalized DRS URI. Never replaces remote identity.

+
CONTENT

Content SHA256

Durable integrity metadata, when supplied. Used to verify bytes—not to locate the record.

+
+ +```text +cache_oid = sha256("git-drs-anvil-ref:v1\n" + normalized_drs_uri) +``` + +> **Download invariant:** fresh access → temporary file → verify size/checksum → atomic cache promotion → hydrate worktree. + +--- + + + +## Good component coverage; one decisive gap + +
+
IMPLEMENTED

Focused coverage

  • Resolver contract and errors
  • Terra remote and safe config
  • Canonical pointer/cache key
  • Terra ping and push refusal
+
PARTIAL

Workflow coverage

  • Manifest validation and dry run
  • Pull size/SHA256 checks
  • Selective hydration
  • Cache reuse behavior
+
MISSING

POC acceptance

  • Independent User A/User B state
  • Real or contract-faithful AnVIL
  • Expired URL and retry journey
  • Authorization leak audit
+
+ +| Arrange | Act | Assert | +|---|---|---| +| Separate homes, config, ADC, caches | Commit → clone → authenticate → pull | Verified bytes; isolated credentials | + +--- + + + +## Most original blockers are now closed + +
+
+ +### Implemented + +- ADC-backed `AnVILResolver` handles metadata and access. +- Terra `add-ref` and pull use provider-neutral resolution. +- Remote config selects behavior; `--remote-type` is deprecated. +- Terra pointers retain DRS URI, size, and optional SHA256. +- Cache keys are separate from content checksums. +
+
+ +### Still incomplete + +- Compact AnVIL IDs still require the trusted Terra resolver. +- Service-info does not discover auth, provider, or capabilities. +- Remote setup retains provider-specific command shapes. +- Downloads lack bounded concurrency, retry, cancellation, and expired-URL re-resolution. +- Production and independent two-user acceptance remain unverified. +
+
+ +> **Bottom line:** an implemented vertical slice still needs production-contract validation, resilience, and end-to-end proof. + +--- + + + +## Turn the vertical slice into a proven POC + +
+
P0 · VERIFY

Prove production fit

  • Confirm endpoint and OAuth scopes
  • Certify object/access contracts
  • Test slash and compact DRS IDs
  • Run clean two-user clone/pull
  • Audit logs and history
+
P1 · HARDEN

Make it resilient

  • Trusted authority routing
  • Persist auth discovery
  • Expired-URL re-resolution
  • Bounded retry and concurrency
  • Remote diagnostics and CI
+
P2 · GENERALIZE

Keep DRS composable

  • Unify `remote add`
  • Resolver/auth interfaces
  • CGC and Synapse fixtures
  • Keep publishing provider-specific
  • Defer mutation and copying
+
+ +--- + +## Decisions needed to start the POC + +1. Which authoritative AnVIL resolver contract, endpoint, and OAuth scopes will we certify? +2. What stable test objects cover checksummed, non-checksummed, large, and denied cases? +3. Is the pointer extension compatible with every parser that must read it? +4. What is the supported override policy for tracked versus local remote configuration? +5. Who owns the independent-user acceptance environment and compatibility matrix? + +### Questions? + +The maintained design source is [`docs/anvil-terra-poc.md`](anvil-terra-poc.md). diff --git a/docs/github-pages.md b/docs/github-pages.md new file mode 100644 index 00000000..91de92a4 --- /dev/null +++ b/docs/github-pages.md @@ -0,0 +1,98 @@ +# Publishing documentation with GitHub Pages + +The [`Publish documentation`](../.github/workflows/pages.yaml) workflow builds +the end-user documentation selected in `mkdocs.yml` and renders the +[`anvil-terra-poc-presentation.md`](anvil-terra-poc-presentation.md) Marp deck. +It publishes both outputs as one GitHub Pages site. Do not edit or commit the +generated HTML. + +## Enable GitHub Pages + +An administrator only needs to configure Pages once for the repository: + +1. Open the repository on GitHub and select **Settings**. +2. Select **Pages** under **Code and automation**. +3. Under **Build and deployment**, set **Source** to **GitHub Actions**. +4. If GitHub prompts you to enable Actions, open **Settings → Actions → + General**, allow GitHub Actions to run, and save the setting. +5. Open **Actions → Publish documentation**, select **Run workflow**, choose the + `main` branch, and select **Run workflow**. +6. When the workflow finishes, its `deploy` job shows the published site URL. + The URL also appears in **Settings → Pages**. + +The repository must permit the official actions used by the workflow. If the +organization restricts actions, allow these actions and their pinned major +versions: + +- `actions/checkout@v4` +- `actions/configure-pages@v5` +- `actions/setup-python@v5` +- `actions/upload-pages-artifact@v3` +- `actions/deploy-pages@v4` + +The workflow declares the required `pages: write` and `id-token: write` +permissions itself. No deploy key, personal access token, or `gh-pages` branch +is required. + +## Publish updates + +After Pages is enabled, push a change under `docs/`, `mkdocs.yml`, or the Pages +workflow to `main`. The workflow runs automatically, renders the selected +Markdown pages with MkDocs, renders the deck with the pinned Marp CLI version, +and deploys the combined site. A maintainer can also publish without a content +change by running the workflow manually from the **Actions** tab. + +The generated site includes: + +```text +/index.html +/quickstart/ +/getting-started/ +/commands/ +/pointer-files/ +/adding-s3-files/ +/remove-files/ +/troubleshooting/ +/anvil-terra-poc-presentation.html +``` + +If the repository's default publishing branch is not `main`, update the +`on.push.branches` value in `.github/workflows/pages.yaml` before relying on +automatic deployments. + +## Preview locally + +With Python, Node.js, and npm installed, stage the selected end-user pages and +build the same combined site as CI: + +```bash +python -m pip install mkdocs-material==9.6.14 +rm -rf .pages-docs _site +mkdir .pages-docs +cp docs/{index,quickstart,getting-started,commands,pointer-files}.md .pages-docs/ +cp docs/{adding-s3-files,remove-files,troubleshooting}.md .pages-docs/ +npx --yes @marp-team/marp-cli@4.2.3 \ + docs/anvil-terra-poc-presentation.md \ + --html \ + --output .pages-docs/anvil-terra-poc-presentation.html +mkdocs build --strict +python -m http.server --directory _site 8000 +``` + +Open `http://localhost:8000/` in a browser. Keep dependency versions in these +commands synchronized with `.github/workflows/pages.yaml` when upgrading the +documentation toolchain. + +## Troubleshooting + +- **The workflow does not start after a push:** confirm the change is on + `main` and touches `docs/**`, `mkdocs.yml`, or + `.github/workflows/pages.yaml`. +- **The deploy job reports that Pages is not enabled:** repeat the enablement + steps and confirm the Pages source is **GitHub Actions**, not **Deploy from a + branch**. +- **An action is blocked:** ask an organization owner to allow the five + official GitHub actions listed above. +- **The page is not immediately available:** wait for the `deploy` job to + finish and use the URL shown in that job rather than guessing the repository + URL. diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 4d5f70db..00000000 --- a/docs/index.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - Git DRS Documentation - - - -
-

Welcome to Git DRS

-

- Git-based Data Repository Service (DRS) integration for Terra/Anvil. - Manage large files and data efficiently with DRS-enabled Git workflows. -

- -
    -
  • Seamless DRS integration with Git
  • -
  • Support for multipart uploads and downloads
  • -
  • Terra/Anvil workspace compatibility
  • -
  • Efficient large file handling
  • -
- -
-

Get started with our Terra/Anvil POC presentation:

- View Presentation → -
-
- - diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..b7671758 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,35 @@ +# Git DRS Documentation + +`git-drs` brings Git-compatible versioning workflows to large data stored +behind Data Repository Service (DRS) providers. Use these end-user guides to +install the command, connect a repository, manage pointer-backed files, and +resolve common problems. + +## Start here + +- [Quick Start](quickstart.md) — install `git-drs`, configure a remote, and + complete the first push and pull workflow. +- [Getting Started](getting-started.md) — understand the Git and DRS mental + model and the most common repository workflows. +- [Commands Reference](commands.md) — look up the supported CLI commands and + their options. + +## Work with data + +- [Pointer Files and Reference State](pointer-files.md) — learn what Git + commits in place of large payloads. +- [Add Existing S3 Objects](adding-s3-files.md) — create references for data + that is already in provider storage. +- [Remove Files](remove-files.md) — remove tracked files and understand remote + reconciliation behavior. +- [Troubleshooting](troubleshooting.md) — diagnose setup, authentication, + tracking, push, and hydration problems. + +## AnVIL/Terra proof of concept + +View the [AnVIL/Terra DRS reference presentation](anvil-terra-poc-presentation.html) +for a visual overview of the reference-only workflow, architecture, security +boundary, and remaining production-validation work. + +The presentation is generated from Markdown during the Pages build; its HTML +output is not stored in the repository. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..62de6f0a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,30 @@ +site_name: Git DRS Documentation +site_description: End-user documentation for Git-compatible DRS workflows +repo_url: https://github.com/calypr/git-drs +docs_dir: .pages-docs +site_dir: _site + +theme: + name: material + features: + - content.code.copy + - navigation.sections + - navigation.top + +markdown_extensions: + - admonition + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + +nav: + - Home: index.md + - Start here: + - Quick Start: quickstart.md + - Getting Started: getting-started.md + - Commands Reference: commands.md + - Work with data: + - Pointer Files and Reference State: pointer-files.md + - Add Existing S3 Objects: adding-s3-files.md + - Remove Files: remove-files.md + - Troubleshooting: troubleshooting.md From 68c083e8d63ea7fc6191a47a842f5e02be0f069f Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 22 Jul 2026 15:34:17 -0700 Subject: [PATCH 39/66] Define DRS preset catalog ownership and safe overrides --- docs/remote-cli-design.md | 179 ++++++++++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 29 deletions(-) diff --git a/docs/remote-cli-design.md b/docs/remote-cli-design.md index 6fd7b3a4..93f4b870 100644 --- a/docs/remote-cli-design.md +++ b/docs/remote-cli-design.md @@ -36,11 +36,14 @@ of slightly different flags for every service. ## Proposed vocabulary -Use one command with two required concepts: a local remote name and a server -endpoint (or a well-known endpoint alias). +Use one command whose only required concept is a server endpoint (or a +well-known endpoint alias). The local remote name should normally be derived +from the alias or endpoint host; an explicit name is only needed to override +that default or to configure the same service more than once. ```text -git drs remote add [flags] +git drs remote add [flags] +git drs remote add [flags] # explicit-name form Flags: --scope Optional provider-specific collection/project scope @@ -54,27 +57,129 @@ Flags: For example: ```sh -# Provider and capabilities discovered from service-info where possible. -git drs remote add cgc https://cgc-ga4gh-api.sbgenomics.com \ - --auth bearer --credential env:CGC_TOKEN +# Presets supply the endpoint, provider, and authentication method. +git drs remote add cgc --credential env:CGC_TOKEN -git drs remote add synapse https://repo-prod.prod.sagebase.org \ - --auth bearer --credential helper:synapse +# A GA4GH Service Registry ID can select a service without spelling its URL. +git drs remote add registry:com.sb.cgc.drs --credential env:CGC_TOKEN + +git drs remote add synapse --credential helper:synapse # A preset may supply a public endpoint and authentication default. -git drs remote add anvil terra --scope my-billing-project/my-workspace +git drs remote add terra --scope my-billing-project/my-workspace -# Gen3 keeps its scope, but no longer needs a different grammar. -git drs remote add production https://example-gen3.org \ +# A custom deployment needs its URL; its name defaults from the host. +git drs remote add https://example-gen3.org \ --provider gen3 --scope PROGRAM/PROJECT \ --credential file:~/.gen3/credentials.json ``` `` should be an HTTPS URL by default. Aliases such as `terra`, `cgc`, and `synapse` are conveniences that expand to versioned, -maintained presets; they are not separate protocols. `--provider` is an escape -hatch when discovery is absent or ambiguous, and should normally remain -`auto`. +maintained presets, including the endpoint and usual authentication method; +they are not separate protocols. Thus `cgc` is not repeated as both a name and +a URL, and users do not have to restate `--auth bearer` when the preset already +knows it. For a URL, derive a stable local name from its host and report it +before saving. If that name already exists, ask for an explicit name rather +than silently replacing it. `--provider` is an escape hatch when discovery is +absent or ambiguous, and should normally remain `auto`. + +### Preset catalog and overrides + +**Implementation status:** this section is a proposal. There is currently no +`internal/presets/presets.yaml`, preset-loading code, or `git drs preset` +command. The implemented `remote add` commands remain the legacy `gen3`, +`local`, and `terra` forms listed in the Problem section. + +When this proposal is implemented, the built-in preset list should be +maintained in a reviewed, version-controlled catalog in this repository. The +proposed location is `internal/presets/presets.yaml`; choosing that path in this +design does not create the file or implementation. The catalog should be +embedded in each `git-drs` release. Each entry should contain only non-secret +defaults: alias, endpoint, provider, authentication method, and optional +registry service ID. The catalog should not be downloaded at command runtime. +The proposed `git drs preset list` and `git drs preset show ` commands +should display the shipped entries, their source (`built-in`, `system`, or +`user`), and the release version that supplied them. + +Preset expansion should be a one-time operation during `remote add`. The +implementation should persist its resolved endpoint, provider, authentication +method, preset alias, and catalog version in the remote configuration, and +print those values before saving. Existing remotes would therefore not change +when a later `git-drs` release edits a preset. The proposed +`git drs remote diagnose ` may report a newer preset and offer an explicit +migration; it must not adopt one automatically. + +The proposed commands would allow users and administrators to add presets in +user and system configuration: + +```sh +git drs preset add lab https://drs.example.edu --provider gen3 --level user +git drs preset add institute https://drs.example.org --level system +``` + +Custom names must not silently shadow built-in aliases. A collision should +fail and direct the user to a qualified selector such as `user:lab` or +`system:lab`; built-ins may be selected explicitly as `built-in:cgc`. This +preserves a short, predictable `cgc` path while allowing local catalogs without +creating a route for configuration to redirect credentials unexpectedly. + +Explicit command-line values take precedence over non-endpoint preset defaults, +so `--auth`, `--provider`, and `--scope` can override the expanded values after +the command prints the effective configuration. To use a different endpoint, +the user should pass that HTTPS URL or a qualified custom preset instead of +overriding the meaning of a built-in alias. Credential sources and secret +values never belong in any preset catalog. + +### Registry discovery + +The [GA4GH Service Registry](https://registry.ga4gh.org/) can remove the need +to type an endpoint for services it lists. Prefer its service `id` as the +selector because that value is intended as an identifier: + +```sh +git drs remote add registry:com.sb.cgc.drs +git drs remote add registry:org.sagebase.prod.repo-prod +``` + +The `registry:` prefix makes network lookup explicit and avoids confusing a +registry selector with a built-in alias, local remote name, or URL. On a +successful lookup, persist the resolved URL and registry ID in the remote +configuration and print the registry name and URL for confirmation. Normal +operations, including `push`, use that persisted endpoint; they must not +silently follow a later registry change on every invocation. An explicit +`remote diagnose` or refresh operation may report a changed registry record +and ask the user whether to adopt it. + +A registry display `name` may be accepted as a convenience only when it has a +single exact match. It is not a reliable primary key: names are human-readable, +can change, can contain spaces, and are not necessarily unique. For example, +the registry currently contains two DRS records named `Canine Data Commons`. +Do not use fuzzy or prefix matching to guess a credential destination. If a +name matches zero or multiple records, list the candidate IDs and require the +user to select an ID or URL. Short forms such as `cgc` remain maintained aliases +rather than fuzzy registry-name searches. + +Consequently, an arbitrary local remote name in a fresh repository cannot by +itself safely determine a server. It can do so only when it is an exact +maintained alias or an explicit, uniquely resolved registry selector. + +Registry lookup discovers a candidate DRS resolver endpoint, not a publishing +contract. Registry records can represent multiple logical datasets at one URL, +and their URLs vary between service roots and paths ending in +`/ga4gh/drs/v1/objects`. Normalize and validate the advertised endpoint through +service-info and capability probing before saving it. In particular, a +registry match must not make `push` assume that upload or registration is +supported; the publisher capability checks described below still apply. If +the registry is unavailable or the record is invalid, fail with an actionable +request for an explicit URL rather than guessing. + +Neither a GA4GH Service Registry record nor the GA4GH service-info response +declares which HTTP `Authorization` scheme or credential flow a DRS deployment +requires. Registry discovery therefore supplies only service identity and a +candidate URL; service-info can confirm service identity and implementation +metadata, but it cannot select `bearer`, `basic`, `google-adc`, or a provider +helper. Do not infer authentication from registry membership or a DRS version. ### Authentication methods @@ -100,17 +205,25 @@ method (not a secret), and show the result to the user. The selection order is: 1. an explicit `--credential` whose type implies a method, if unambiguous; 2. the endpoint alias's maintained default (for example, a Terra alias may select `google-adc`); -3. authentication metadata returned by trusted service discovery; -4. a single installed provider helper registered for the detected provider; -5. `none` only when an anonymous capability probe succeeds. +3. a single installed provider helper registered for the detected provider; +4. `none` only when an anonymous capability probe succeeds. If the evidence is absent, conflicting, or names multiple usable methods, `remote add` should stop and ask the user to choose `--auth`; it must not send available credentials to a server merely because they exist. A 401 challenge -may refine discovery, but it must not trigger a loop that tries bearer, basic, -and local credential stores in turn. Subsequent commands use the persisted -method, so `auto` does not make network behavior vary from invocation to -invocation. `git drs remote diagnose` may explicitly rerun discovery. +with a standards-compliant `WWW-Authenticate` header may identify an HTTP +scheme, but the registry and service-info cannot. A challenge must not trigger +a loop that tries bearer, basic, and local credential stores in turn. +Subsequent commands use the persisted method, so `auto` does not make network +behavior vary from invocation to invocation. `git drs remote diagnose` may +explicitly rerun discovery. + +The common preset path should require no explicit `--auth`. For example, the +CGC and Synapse presets select bearer authentication, while a Terra preset may +select `google-adc`. `--auth` remains available for custom deployments, +overrides, and ambiguous discovery; it is not routine boilerplate. Supplying a +credential source whose type unambiguously implies a method likewise makes an +explicit `--auth` unnecessary. `provider-helper` is for authentication lifecycles that the generic methods cannot represent. Examples include a Gen3 profile helper that exchanges an API @@ -140,9 +253,12 @@ git drs remote add production https://example-gen3.org \ ### Defaults remove flags -- Discover the provider and supported features using GA4GH service-info and a - small, maintained fingerprint table. Print the detected provider before - persisting configuration. +- Identify the service and provider using GA4GH service-info and a small, + maintained fingerprint table after resolving any explicit registry selector. + Probe resolver and publisher capabilities separately; service-info does not + advertise authentication or upload/registration support. Print the registry + match, endpoint, detected provider, and probed capabilities before persisting + configuration. - Resolve `--auth auto` using the deterministic authentication selection above. Do not silently downgrade from authenticated to anonymous access. - Infer read-only versus writable from capability probing. Replace `--mode` @@ -252,8 +368,9 @@ silently diverge. Introduce the unified form without immediately removing scripts: -1. Add `remote add ` and store the compositional - remote configuration. +1. Add `remote add ` with a derived local name, support + explicit `registry:` lookup, retain the explicit-name form for + overrides, and store the resolved compositional remote configuration. 2. Keep `remote add gen3`, `local`, and `terra` as hidden or deprecated compatibility shims that translate their arguments to the new model. 3. Add `git drs remote migrate` and make `remote list --verbose` display the @@ -267,13 +384,17 @@ Introduce the unified form without immediately removing scripts: The minimal common case then becomes memorable: ```sh -git drs remote add +git drs remote add ``` -Scope, authentication, publishing storage, and checkout policy appear only -when the server or workflow actually needs them. +An explicit local name, scope, authentication override, publishing storage, +and checkout policy appear only when the server or workflow actually needs +them. A credential source may still be required, but a preset or unambiguous +source prevents the user from also having to state the corresponding +authentication method. ## References - [Cancer Genomics Cloud DRS API overview](https://docs.cancergenomicscloud.org/reference/drs-api-overview) +- [GA4GH Service Registry](https://registry.ga4gh.org/) - [Synapse DRS controller REST documentation](https://rest-docs.synapse.org/rest/index.html#org.sagebionetworks.drs.controller.DrsController) From 521d994b930d6c6bc0c542f5e26586f0c3c0faba Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 22 Jul 2026 16:07:35 -0700 Subject: [PATCH 40/66] Implement unified remote CLI and built-in DRS presets --- README.md | 7 +- cmd/preset/root.go | 40 +++++++++ cmd/remote/add/init.go | 16 +++- cmd/remote/add/terra.go | 5 +- cmd/remote/add/unified.go | 141 ++++++++++++++++++++++++++++++ cmd/remote/add/unified_test.go | 57 ++++++++++++ cmd/remote/list.go | 6 ++ cmd/root.go | 2 + docs/commands.md | 32 +++++++ docs/remote-cli-design.md | 12 +-- internal/config/config.go | 48 ++++++++++ internal/config/remote.go | 23 ++++- internal/presets/presets.go | 54 ++++++++++++ internal/presets/presets.yaml | 20 +++++ internal/presets/presets_test.go | 19 ++++ internal/remoteruntime/runtime.go | 6 ++ 16 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 cmd/preset/root.go create mode 100644 cmd/remote/add/unified.go create mode 100644 cmd/remote/add/unified_test.go create mode 100644 internal/presets/presets.go create mode 100644 internal/presets/presets.yaml create mode 100644 internal/presets/presets_test.go diff --git a/README.md b/README.md index 65c2ec7b..16cf9056 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ At a high level: ```bash git drs install -git drs remote add gen3 production HTAN_INT/BForePC --cred /path/to/credentials.json +git drs remote add production https://example-gen3.org --provider gen3 \ + --scope HTAN_INT/BForePC --auth provider-helper:gen3-profile \ + --credential file:/path/to/credentials.json git drs track "*.bam" git add .gitattributes git add sample.bam @@ -91,7 +93,8 @@ Push and pull depend on server-side bucket mapping for the requested scope. That | --- | --- | | `git drs install` | Install global `git-drs` filter config | | `git drs init` | Explicitly initialize or repair repository-local `git-drs` state | -| `git drs remote add gen3 [remote] ` | Add or refresh a Gen3/Syfon remote | +| `git drs remote add ` | Add a DRS remote (built-ins: calypr, terra, synapse, cgc) | +| `git drs preset list` | List the non-secret presets embedded in this release | | `git drs remote list` | List configured remotes | | `git drs remote remove ` | Remove a configured DRS remote | | `git drs remote set ` | Set the default remote | diff --git a/cmd/preset/root.go b/cmd/preset/root.go new file mode 100644 index 00000000..9ae0d531 --- /dev/null +++ b/cmd/preset/root.go @@ -0,0 +1,40 @@ +package preset + +import ( + "fmt" + "sort" + + "github.com/calypr/git-drs/internal/presets" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{Use: "preset", Short: "Inspect built-in DRS server presets"} + +var listCmd = &cobra.Command{Use: "list", Short: "List built-in presets", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + items, err := presets.List() + if err != nil { + return err + } + sort.Slice(items, func(i, j int) bool { return items[i].Alias < items[j].Alias }) + for _, p := range items { + fmt.Fprintf(cmd.OutOrStdout(), "%-10s %-8s %s built-in v%d\n", p.Alias, p.Provider, p.Endpoint, presets.CatalogVersion) + } + return nil +}} + +var showCmd = &cobra.Command{Use: "show ", Short: "Show a built-in preset", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + p, ok, err := presets.Lookup(args[0]) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("preset %q not found", args[0]) + } + fmt.Fprintf(cmd.OutOrStdout(), "alias: %s\nendpoint: %s\nprovider: %s\nauth: %s\nsource: built-in\ncatalog-version: %d\n", p.Alias, p.Endpoint, p.Provider, p.Auth, presets.CatalogVersion) + if p.RegistryServiceID != "" { + fmt.Fprintf(cmd.OutOrStdout(), "registry-service-id: %s\n", p.RegistryServiceID) + } + return nil +}} + +func init() { Cmd.AddCommand(listCmd, showCmd) } diff --git a/cmd/remote/add/init.go b/cmd/remote/add/init.go index 514a01e7..616a6328 100644 --- a/cmd/remote/add/init.go +++ b/cmd/remote/add/init.go @@ -17,24 +17,38 @@ var ( // Cmd line declaration var Cmd = &cobra.Command{ Use: "add", - Short: "add server access for git-drs", + Short: "Add a DRS server using an endpoint or built-in preset", + Args: cobra.RangeArgs(1, 2), + RunE: runUnified, } func init() { + Cmd.PersistentFlags().StringVar(&scopeFlag, "scope", "", "Provider-specific organization/project scope") + Cmd.PersistentFlags().StringVar(&authFlag, "auth", "auto", "Authentication method") + Cmd.PersistentFlags().StringVar(&credentialFlag, "credential", "", "Credential source (never an inline secret)") + Cmd.PersistentFlags().StringVar(&providerFlag, "provider", "auto", "DRS provider adapter") + Cmd.PersistentFlags().StringVar(&storageFlag, "storage", "", "Advanced publishing bucket/prefix") + Cmd.PersistentFlags().StringVar(&checkoutFlag, "checkout", "", "Checkout mode: pointers or hydrate") Gen3Cmd.Flags().StringVar(&credFile, "cred", "", "[gen3] Import a Gen3 credential file into this profile") Gen3Cmd.Flags().StringVar(&fenceToken, "token", "", "[gen3] Use a temporary bearer token issued from fence") Gen3Cmd.Flags().StringVar(&selectedBucket, "bucket", "", "[gen3] Select a specific visible bucket when multiple buckets match the scope") Gen3Cmd.Flags().BoolVar(&noSkipSmudge, "no-skip-smudge", false, "Disable skipping smudge filter (force downloading file contents during checkout)") Cmd.AddCommand(Gen3Cmd) + Gen3Cmd.Deprecated = "use 'git drs remote add --provider gen3 --scope --credential '" + Gen3Cmd.Hidden = true LocalCmd.Flags().StringVar(&selectedBucket, "bucket", "", "Select a specific visible bucket when multiple buckets match the scope") LocalCmd.Flags().StringVar(&localUsername, "username", "", "Username for local DRS HTTP basic auth") LocalCmd.Flags().StringVar(&localPassword, "password", "", "Password for local DRS HTTP basic auth") LocalCmd.Flags().BoolVar(&noSkipSmudge, "no-skip-smudge", false, "Disable skipping smudge filter (force downloading file contents during checkout)") Cmd.AddCommand(LocalCmd) + LocalCmd.Deprecated = "use the unified endpoint form" + LocalCmd.Hidden = true TerraCmd.Flags().StringVar(&terraEndpoint, "drs-endpoint", "", "Terra DRS service base URL") TerraCmd.Flags().StringVar(&terraAuth, "auth", "", "Terra authentication method (google-adc)") TerraCmd.Flags().StringVar(&terraMode, "mode", "", "Terra remote mode (read-only)") Cmd.AddCommand(TerraCmd) + TerraCmd.Deprecated = "use 'git drs remote add terra'" + TerraCmd.Hidden = true } diff --git a/cmd/remote/add/terra.go b/cmd/remote/add/terra.go index c3da6892..07c53f4b 100644 --- a/cmd/remote/add/terra.go +++ b/cmd/remote/add/terra.go @@ -14,8 +14,11 @@ import ( var TerraCmd = &cobra.Command{ Use: "terra ", Short: "Add a read-only Terra DRS remote", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return runUnified(cmd, []string{"terra"}) + } remoteName := strings.TrimSpace(args[0]) if remoteName == "" { return fmt.Errorf("remote name is required") diff --git a/cmd/remote/add/unified.go b/cmd/remote/add/unified.go new file mode 100644 index 00000000..b887c24e --- /dev/null +++ b/cmd/remote/add/unified.go @@ -0,0 +1,141 @@ +package add + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/calypr/git-drs/cmd/initialize" + "github.com/calypr/git-drs/internal/config" + "github.com/calypr/git-drs/internal/drslog" + "github.com/calypr/git-drs/internal/gitrepo" + "github.com/calypr/git-drs/internal/presets" + "github.com/spf13/cobra" +) + +var ( + scopeFlag, authFlag, credentialFlag, providerFlag string + storageFlag, checkoutFlag string +) + +func runUnified(cmd *cobra.Command, args []string) error { + if len(args) < 1 || len(args) > 2 { + return fmt.Errorf("expected or ") + } + name, selector := "", args[0] + if len(args) == 2 { + name, selector = strings.TrimSpace(args[0]), args[1] + } + if strings.HasPrefix(selector, "registry:") { + return fmt.Errorf("registry selector %q requires registry discovery, which is unavailable; pass the service HTTPS URL explicitly", selector) + } + + preset, isPreset, err := presets.Lookup(selector) + if err != nil { + return err + } + endpoint, provider, auth, presetName, registryID, version := selector, "auto", "auto", "", "", 0 + if isPreset { + endpoint, provider, auth = preset.Endpoint, preset.Provider, preset.Auth + presetName, registryID, version = preset.Alias, preset.RegistryServiceID, presets.CatalogVersion + } + if providerFlag != "" && providerFlag != "auto" { + provider = providerFlag + } + if authFlag != "" && authFlag != "auto" { + auth = authFlag + } + if !validChoice(provider, "auto", "ga4gh", "gen3", "terra", "cgc", "synapse") { + return fmt.Errorf("unsupported provider %q", provider) + } + if !validAuth(auth) { + return fmt.Errorf("unsupported authentication method %q", auth) + } + u, err := url.ParseRequestURI(strings.TrimSpace(endpoint)) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return fmt.Errorf("endpoint must be an HTTPS URL without credentials, or a built-in alias") + } + if name == "" { + name = deriveRemoteName(presetName, u.Hostname()) + } + if name == "" { + return fmt.Errorf("could not derive a remote name; use the explicit-name form") + } + if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(name) { + return fmt.Errorf("invalid remote name %q", name) + } + if scopeFlag != "" { + if _, _, err := parseScopeArg(scopeFlag); err != nil { + return err + } + } + if credentialFlag != "" && !validCredentialSource(credentialFlag) { + return fmt.Errorf("invalid credential source %q; use env:, file:, helper:, profile:, or stdin", credentialFlag) + } + if checkoutFlag != "" && !validChoice(checkoutFlag, "pointers", "hydrate") { + return fmt.Errorf("--checkout must be pointers or hydrate") + } + + if err := initialize.EnsureInitialized(drslog.GetLogger()); err != nil { + return fmt.Errorf("failed to initialize repository: %w", err) + } + if cfg, loadErr := config.LoadConfig(); loadErr == nil { + if _, exists := cfg.Remotes[config.Remote(name)]; exists { + return fmt.Errorf("remote %q already exists; choose an explicit name or remove it first", name) + } + } + r := &config.GenericRemote{Endpoint: u.String(), Provider: provider, Auth: auth, Credential: credentialFlag, Scope: scopeFlag, Storage: storageFlag, Checkout: checkoutFlag, Preset: presetName, PresetVersion: version, RegistryServiceID: registryID} + fmt.Fprintf(cmd.OutOrStdout(), "Remote: %s\nEndpoint: %s\nProvider: %s\nAuth: %s\n", name, r.Endpoint, r.Provider, r.Auth) + if r.Preset != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Preset: %s (built-in catalog v%d)\n", r.Preset, r.PresetVersion) + } + if _, err := config.UpdateRemote(config.Remote(name), config.RemoteSelect{Generic: r}); err != nil { + return fmt.Errorf("save remote: %w", err) + } + if err := configureRepoRemote(name, r.Endpoint); err != nil { + return err + } + if checkoutFlag != "" { + skip := "true" + if checkoutFlag == "hydrate" { + skip = "false" + } + if err := gitrepo.SetGitConfigOptions(map[string]string{"drs.skipsmudge": skip}); err != nil { + return err + } + } + fmt.Fprintln(cmd.OutOrStdout(), "Remote saved.") + return nil +} + +func deriveRemoteName(alias, host string) string { + if alias != "" { + return alias + } + host = strings.ToLower(strings.TrimSpace(host)) + host = strings.TrimPrefix(host, "www.") + return regexp.MustCompile(`[^a-z0-9._-]+`).ReplaceAllString(host, "-") +} +func validChoice(v string, choices ...string) bool { + for _, c := range choices { + if v == c { + return true + } + } + return false +} +func validAuth(v string) bool { + return validChoice(v, "auto", "none", "bearer", "basic", "google-adc", "provider-helper") || strings.HasPrefix(v, "provider-helper:") +} +func validCredentialSource(v string) bool { + if v == "stdin" { + return true + } + for _, p := range []string{"env:", "file:", "helper:", "profile:"} { + if strings.HasPrefix(v, p) && len(v) > len(p) { + return true + } + } + return false +} diff --git a/cmd/remote/add/unified_test.go b/cmd/remote/add/unified_test.go new file mode 100644 index 00000000..3710e0ce --- /dev/null +++ b/cmd/remote/add/unified_test.go @@ -0,0 +1,57 @@ +package add + +import ( + "bytes" + "testing" + + "github.com/calypr/git-drs/internal/config" + "github.com/calypr/git-drs/internal/testutils" +) + +func TestUnifiedAddExpandsPreset(t *testing.T) { + testutils.SetupTestGitRepo(t) + resetUnifiedFlags(t) + credentialFlag = "env:CGC_TOKEN" + var out bytes.Buffer + Cmd.SetOut(&out) + if err := runUnified(Cmd, []string{"cgc"}); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadConfig() + if err != nil { + t.Fatal(err) + } + r := cfg.Remotes["cgc"].Generic + if r == nil || r.Provider != "cgc" || r.Auth != "bearer" || r.PresetVersion != 1 || r.Credential != "env:CGC_TOKEN" { + t.Fatalf("unexpected remote: %+v", r) + } + if out.String() == "" { + t.Fatal("expected effective configuration output") + } +} + +func TestUnifiedAddDerivesURLNameAndRejectsHTTP(t *testing.T) { + testutils.SetupTestGitRepo(t) + resetUnifiedFlags(t) + if err := runUnified(Cmd, []string{"http://drs.example.org"}); err == nil { + t.Fatal("expected HTTP rejection") + } + if err := runUnified(Cmd, []string{"https://drs.example.org"}); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.Remotes["drs.example.org"].Generic == nil { + t.Fatal("derived remote not persisted") + } +} + +func resetUnifiedFlags(t *testing.T) { + old := []string{scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag} + scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag = "", "auto", "", "auto", "", "" + t.Cleanup(func() { + scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag = old[0], old[1], old[2], old[3], old[4], old[5] + }) +} diff --git a/cmd/remote/list.go b/cmd/remote/list.go index a7a3035d..b7e2911e 100644 --- a/cmd/remote/list.go +++ b/cmd/remote/list.go @@ -54,6 +54,12 @@ var ListCmd = &cobra.Command{ } else if remoteSelect.Local != nil { remoteType = string(config.LocalServerType) remote = remoteSelect.Local + } else if remoteSelect.Terra != nil { + remoteType = string(config.TerraServerType) + remote = remoteSelect.Terra + } else if remoteSelect.Generic != nil { + remoteType = remoteSelect.Generic.Provider + remote = remoteSelect.Generic } else { remoteType = "unknown" } diff --git a/cmd/root.go b/cmd/root.go index 320592f8..bd78ef18 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/calypr/git-drs/cmd/lsfiles" "github.com/calypr/git-drs/cmd/ping" "github.com/calypr/git-drs/cmd/precommit" + "github.com/calypr/git-drs/cmd/preset" "github.com/calypr/git-drs/cmd/pull" "github.com/calypr/git-drs/cmd/push" "github.com/calypr/git-drs/cmd/query" @@ -50,6 +51,7 @@ func init() { RootCmd.AddCommand(pull.Cmd) RootCmd.AddCommand(push.Cmd) RootCmd.AddCommand(precommit.Cmd) + RootCmd.AddCommand(preset.Cmd) RootCmd.AddCommand(addref.Cmd) RootCmd.AddCommand(addurl.Cmd) RootCmd.AddCommand(deleteCmd.Cmd) diff --git a/docs/commands.md b/docs/commands.md index fe371139..a8b52276 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -35,6 +35,38 @@ Use this when you want explicit initialization or to repair repo-local hooks/con ## Remote Configuration +### `git drs remote add [flags]` + +Add a DRS server with the unified remote command. The built-in aliases are +`calypr`, `terra`, `synapse`, and `cgc`; inspect their non-secret defaults with +`git drs preset list` or `git drs preset show `. + +```bash +git drs remote add cgc --credential env:CGC_TOKEN +git drs remote add synapse --credential helper:synapse +git drs remote add https://drs.example.org --provider ga4gh --auth none +git drs remote add research https://gen3.example.org \ + --provider gen3 --scope PROGRAM/PROJECT \ + --auth provider-helper:gen3-profile --credential profile:research +``` + +For a URL, the local name is derived from the host. Use the two-argument form +to choose a name explicitly. `--credential` accepts `env:`, `file:`, `helper:`, +`profile:`, or `stdin`; it never accepts an inline secret. Other options are +`--scope`, `--auth`, `--provider`, `--storage`, and `--checkout`. Presets are +expanded once and the resolved endpoint, provider, authentication method, and +catalog version are saved, so a later release cannot silently redirect an +existing remote. Only HTTPS endpoints without embedded credentials are +accepted. + +The older `gen3`, `local`, and `terra` command shapes are deprecated, hidden +compatibility forms. New scripts should use the unified command. + +### `git drs preset list` / `git drs preset show ` + +Display the presets embedded in this release. Presets contain endpoints and +authentication methods but never credentials or secret values. + ### `git drs remote add gen3 [remote-name] ` Add or refresh a Gen3-backed Syfon remote. diff --git a/docs/remote-cli-design.md b/docs/remote-cli-design.md index 93f4b870..1175fca6 100644 --- a/docs/remote-cli-design.md +++ b/docs/remote-cli-design.md @@ -2,8 +2,9 @@ ## Status -Proposed. This document describes a migration target rather than the current -command-line contract. +Implemented for built-in presets and unified endpoint configuration. Registry +discovery, custom user/system preset catalogs, capability probing, and remote +migration remain future work. ## Problem @@ -86,10 +87,9 @@ absent or ambiguous, and should normally remain `auto`. ### Preset catalog and overrides -**Implementation status:** this section is a proposal. There is currently no -`internal/presets/presets.yaml`, preset-loading code, or `git drs preset` -command. The implemented `remote add` commands remain the legacy `gen3`, -`local`, and `terra` forms listed in the Problem section. +The built-in catalog is embedded from `internal/presets/presets.yaml` and can +be inspected with `git drs preset list` and `git drs preset show `. +Legacy provider-specific add commands remain hidden compatibility shims. When this proposal is implemented, the built-in preset list should be maintained in a reviewed, version-controlled catalog in this repository. The diff --git a/internal/config/config.go b/internal/config/config.go index 52922e22..9acaf783 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "github.com/calypr/git-drs/internal/gitrepo" @@ -26,6 +27,7 @@ const ( Gen3ServerType RemoteType = "gen3" LocalServerType RemoteType = "local" TerraServerType RemoteType = "terra" + GA4GHServerType RemoteType = "ga4gh" configSection = "drs" remoteSubsectionPrefix = "remote." @@ -50,6 +52,8 @@ func (c Config) GetRemote(remote Remote) DRSRemote { return x.Local } else if x.Terra != nil { return x.Terra + } else if x.Generic != nil { + return x.Generic } return nil } @@ -162,6 +166,20 @@ func UpdateRemote(name Remote, remote RemoteSelect) (*Config, error) { if remote.Local.StoragePrefix != "" { remoteSubsection.SetOption("storage_prefix", remote.Local.StoragePrefix) } + } else if remote.Generic != nil { + r := remote.Generic + remoteSubsection.SetOption("type", "ga4gh") + remoteSubsection.SetOption("endpoint", r.Endpoint) + remoteSubsection.SetOption("provider", r.Provider) + remoteSubsection.SetOption("auth", r.Auth) + for key, value := range map[string]string{"credential": r.Credential, "scope": r.Scope, "storage": r.Storage, "checkout": r.Checkout, "preset": r.Preset, "registry-service-id": r.RegistryServiceID} { + if value != "" { + remoteSubsection.SetOption(key, value) + } + } + if r.PresetVersion > 0 { + remoteSubsection.SetOption("preset-version", fmt.Sprint(r.PresetVersion)) + } } // Set default remote if not set @@ -214,6 +232,16 @@ func parseAndAddRemote(cfg *Config, subsectionName string, remoteType string, en cfg.Remotes[remoteName] = rs } +func addGenericRemote(cfg *Config, name Remote, opts map[string]string) { + version, _ := strconv.Atoi(opts["preset-version"]) + cfg.Remotes[name] = RemoteSelect{Generic: &GenericRemote{ + Endpoint: opts["endpoint"], Provider: opts["provider"], Auth: opts["auth"], + Credential: opts["credential"], Scope: opts["scope"], Storage: opts["storage"], + Checkout: opts["checkout"], Preset: opts["preset"], PresetVersion: version, + RegistryServiceID: opts["registry-service-id"], + }} +} + func loadGitConfigOverrides(cfg *Config) error { cmd := exec.Command("git", "config", "--local", "--get-regexp", `^drs\.`) out, err := cmd.Output() @@ -256,6 +284,10 @@ func loadGitConfigOverrides(cfg *Config) error { } for name, opts := range remoteOptions { + if opts["type"] == "ga4gh" { + addGenericRemote(cfg, name, opts) + continue + } parseAndAddRemote( cfg, remoteSubsectionPrefix+string(name), @@ -307,6 +339,14 @@ func LoadConfig() (*Config, error) { if !strings.HasPrefix(subsection.Name, remoteSubsectionPrefix) { continue } + if subsection.Option("type") == "ga4gh" { + opts := make(map[string]string) + for _, key := range []string{"endpoint", "provider", "auth", "credential", "scope", "storage", "checkout", "preset", "preset-version", "registry-service-id"} { + opts[key] = subsection.Option(key) + } + addGenericRemote(cfg, Remote(strings.TrimPrefix(subsection.Name, remoteSubsectionPrefix)), opts) + continue + } parseAndAddRemote( cfg, subsection.Name, @@ -377,6 +417,14 @@ func RemoveRemote(name Remote) (*Config, error) { fmt.Sprintf("drs.remote.%s.storage_prefix", name), fmt.Sprintf("drs.remote.%s.auth", name), fmt.Sprintf("drs.remote.%s.mode", name), + fmt.Sprintf("drs.remote.%s.provider", name), + fmt.Sprintf("drs.remote.%s.credential", name), + fmt.Sprintf("drs.remote.%s.scope", name), + fmt.Sprintf("drs.remote.%s.storage", name), + fmt.Sprintf("drs.remote.%s.checkout", name), + fmt.Sprintf("drs.remote.%s.preset", name), + fmt.Sprintf("drs.remote.%s.preset-version", name), + fmt.Sprintf("drs.remote.%s.registry-service-id", name), fmt.Sprintf("drs.remote.%s.token", name), fmt.Sprintf("drs.remote.%s.username", name), fmt.Sprintf("drs.remote.%s.password", name), diff --git a/internal/config/remote.go b/internal/config/remote.go index 000e8bf9..a7b5fd40 100644 --- a/internal/config/remote.go +++ b/internal/config/remote.go @@ -1,5 +1,7 @@ package config +import "strings" + type DRSRemote interface { GetProjectId() string GetOrganization() string @@ -9,11 +11,26 @@ type DRSRemote interface { } type RemoteSelect struct { - Gen3 *Gen3Remote - Local *LocalRemote - Terra *TerraRemote + Gen3 *Gen3Remote + Local *LocalRemote + Terra *TerraRemote + Generic *GenericRemote +} + +// GenericRemote is the compositional configuration produced by the unified +// remote-add command. Credential contains a source identifier, never a secret. +type GenericRemote struct { + Endpoint, Provider, Auth, Credential, Scope, Storage, Checkout string + Preset, RegistryServiceID string + PresetVersion int } +func (r GenericRemote) GetProjectId() string { _, p, _ := strings.Cut(r.Scope, "/"); return p } +func (r GenericRemote) GetOrganization() string { o, _, _ := strings.Cut(r.Scope, "/"); return o } +func (r GenericRemote) GetEndpoint() string { return r.Endpoint } +func (r GenericRemote) GetBucketName() string { b, _, _ := strings.Cut(r.Storage, "/"); return b } +func (r GenericRemote) GetStoragePrefix() string { _, p, _ := strings.Cut(r.Storage, "/"); return p } + type Gen3Remote struct { Endpoint string `yaml:"endpoint"` ProjectID string `yaml:"project_id"` diff --git a/internal/presets/presets.go b/internal/presets/presets.go new file mode 100644 index 00000000..fcc0b9db --- /dev/null +++ b/internal/presets/presets.go @@ -0,0 +1,54 @@ +// Package presets exposes the non-secret remote defaults shipped with git-drs. +package presets + +import ( + _ "embed" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// CatalogVersion is persisted when a preset is expanded. +const CatalogVersion = 1 + +type Preset struct { + Alias string `yaml:"alias"` + Endpoint string `yaml:"endpoint"` + Provider string `yaml:"provider"` + Auth string `yaml:"auth"` + RegistryServiceID string `yaml:"registry_service_id,omitempty"` +} + +type catalog struct { + Version int `yaml:"version"` + Presets []Preset `yaml:"presets"` +} + +//go:embed presets.yaml +var builtIn []byte + +func List() ([]Preset, error) { + var c catalog + if err := yaml.Unmarshal(builtIn, &c); err != nil { + return nil, fmt.Errorf("decode built-in preset catalog: %w", err) + } + if c.Version != CatalogVersion { + return nil, fmt.Errorf("unsupported built-in preset catalog version %d", c.Version) + } + return append([]Preset(nil), c.Presets...), nil +} + +func Lookup(alias string) (Preset, bool, error) { + alias = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(alias)), "built-in:") + items, err := List() + if err != nil { + return Preset{}, false, err + } + for _, item := range items { + if item.Alias == alias { + return item, true, nil + } + } + return Preset{}, false, nil +} diff --git a/internal/presets/presets.yaml b/internal/presets/presets.yaml new file mode 100644 index 00000000..4d3a6bfe --- /dev/null +++ b/internal/presets/presets.yaml @@ -0,0 +1,20 @@ +version: 1 +presets: + - alias: calypr + endpoint: https://calypr.org + provider: gen3 + auth: provider-helper:gen3-profile + - alias: terra + endpoint: https://data.terra.bio + provider: terra + auth: google-adc + - alias: synapse + endpoint: https://repo-prod.prod.sagebase.org/ga4gh/drs/v1 + provider: synapse + auth: bearer + registry_service_id: org.sagebase.prod.repo-prod + - alias: cgc + endpoint: https://cgc-ga4gh-api.sbgenomics.com/ga4gh/drs/v1 + provider: cgc + auth: bearer + registry_service_id: com.sb.cgc.drs diff --git a/internal/presets/presets_test.go b/internal/presets/presets_test.go new file mode 100644 index 00000000..af58f419 --- /dev/null +++ b/internal/presets/presets_test.go @@ -0,0 +1,19 @@ +package presets + +import "testing" + +func TestBuiltInCatalog(t *testing.T) { + items, err := List() + if err != nil { + t.Fatal(err) + } + if len(items) != 4 { + t.Fatalf("got %d presets, want 4", len(items)) + } + for _, alias := range []string{"calypr", "terra", "synapse", "cgc"} { + p, ok, err := Lookup(alias) + if err != nil || !ok || p.Endpoint == "" || p.Provider == "" || p.Auth == "" { + t.Fatalf("invalid %s preset: %+v, %v", alias, p, err) + } + } +} diff --git a/internal/remoteruntime/runtime.go b/internal/remoteruntime/runtime.go index 009fbda2..baf8b298 100644 --- a/internal/remoteruntime/runtime.go +++ b/internal/remoteruntime/runtime.go @@ -52,6 +52,12 @@ func New(cfg *config.Config, remote config.Remote, logger *slog.Logger) (*GitCon if x.Terra != nil { return terraClient(*x.Terra, logger) } + if x.Generic != nil { + if x.Generic.Provider == "terra" { + return terraClient(config.TerraRemote{Endpoint: x.Generic.Endpoint, Auth: x.Generic.Auth, Mode: "read-only"}, logger) + } + return &GitContext{RemoteType: config.GA4GHServerType, Endpoint: x.Generic.Endpoint, Organization: x.Generic.GetOrganization(), ProjectId: x.Generic.GetProjectId(), Logger: logger, Credential: &syconf.Credential{APIEndpoint: x.Generic.Endpoint}}, nil + } return nil, fmt.Errorf("no valid remote configuration found for current remote: %s", remote) } From d598b8d2b7108027f6c675f4f18748a9137c7021 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 22 Jul 2026 16:29:08 -0700 Subject: [PATCH 41/66] Update user docs for unified remote presets --- docs/commands.md | 83 ++++++++++++++++------------------------- docs/getting-started.md | 47 ++++++++++++++++++----- docs/quickstart.md | 31 +++++++++++++-- 3 files changed, 97 insertions(+), 64 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index a8b52276..369ac9eb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -35,7 +35,7 @@ Use this when you want explicit initialization or to repair repo-local hooks/con ## Remote Configuration -### `git drs remote add [flags]` +### `git drs remote add [name] [flags]` Add a DRS server with the unified remote command. The built-in aliases are `calypr`, `terra`, `synapse`, and `cgc`; inspect their non-secret defaults with @@ -44,16 +44,23 @@ Add a DRS server with the unified remote command. The built-in aliases are ```bash git drs remote add cgc --credential env:CGC_TOKEN git drs remote add synapse --credential helper:synapse +git drs remote add terra --scope my-billing-project/my-workspace git drs remote add https://drs.example.org --provider ga4gh --auth none git drs remote add research https://gen3.example.org \ --provider gen3 --scope PROGRAM/PROJECT \ --auth provider-helper:gen3-profile --credential profile:research ``` -For a URL, the local name is derived from the host. Use the two-argument form -to choose a name explicitly. `--credential` accepts `env:`, `file:`, `helper:`, -`profile:`, or `stdin`; it never accepts an inline secret. Other options are -`--scope`, `--auth`, `--provider`, `--storage`, and `--checkout`. Presets are +For a preset, its alias becomes the default local name; for a URL, the name is +derived from the host. Use the two-argument form to choose a name explicitly. +`--credential` accepts `env:VARIABLE`, `file:PATH`, `helper:NAME`, +`profile:NAME`, or `stdin`; it never accepts an inline secret. Other options +are `--scope`, `--auth`, `--provider`, `--storage`, and `--checkout`. Checkout +mode is `pointers` or `hydrate`. + +Provider values are `auto`, `ga4gh`, `gen3`, `terra`, `cgc`, and `synapse`. +Authentication values are `auto`, `none`, `bearer`, `basic`, `google-adc`, +`provider-helper`, or `provider-helper:`. Presets are expanded once and the resolved endpoint, provider, authentication method, and catalog version are saved, so a later release cannot silently redirect an existing remote. Only HTTPS endpoints without embedded credentials are @@ -64,40 +71,21 @@ compatibility forms. New scripts should use the unified command. ### `git drs preset list` / `git drs preset show ` -Display the presets embedded in this release. Presets contain endpoints and -authentication methods but never credentials or secret values. - -### `git drs remote add gen3 [remote-name] ` - -Add or refresh a Gen3-backed Syfon remote. - -```bash -git drs remote add gen3 [remote-name] --cred -git drs remote add gen3 [remote-name] --token -``` - -Notes: +Display the presets embedded in this release. `list` shows each alias, +provider, endpoint, and catalog version. `show` also displays its authentication +default and, when present, registry service ID. Presets contain no credential +or secret values. -- `remote-name` is optional; if omitted, the default remote name is used -- scope is always one positional argument: `organization/project` -- `--cred` imports a Gen3 credential file -- `--token` uses a temporary bearer token -- if repo-local `git-drs` setup is missing, this command bootstraps it -- bucket resolution is scope-driven; users normally do not provide `--bucket` - -### `git drs remote add local ` - -Add or refresh a local Syfon/DRS remote. - -```bash -git drs remote add local local-dev http://localhost:8080 HTAN_INT/BForePC -git drs remote add local local-dev http://localhost:8080 HTAN_INT/BForePC --username drs-user --password drs-pass -``` - -Notes: +| Alias | Provider | Authentication default | +| --- | --- | --- | +| `calypr` | Gen3 | Gen3 profile provider helper | +| `terra` | Terra | Google Application Default Credentials | +| `synapse` | Synapse | bearer token | +| `cgc` | Cancer Genomics Cloud | bearer token | -- local mode can store HTTP basic auth for helper flows -- repo-local `git-drs` setup is also bootstrapped here when missing +The legacy provider-specific `remote add gen3`, `remote add local`, and +`remote add terra` forms remain only as hidden, deprecated compatibility +commands. Do not use them in new instructions or scripts. ### `git drs remote list` @@ -124,24 +112,20 @@ What it checks: - for Terra/AnVIL TDR-hosted data in production, use `https://data.terra.bio` as the endpoint; Terra also uses DRSHub for DRS URI resolution, but DRSHub is a resolver service rather than the GA4GH DRS service-info host - for scoped Syfon-style remotes, verifies that the configured organization/project and bucket are visible and readable -Example Terra configuration and ping: +Example Terra preset configuration and ping: ```bash -git config drs.default-remote anvil -git config drs.remote.anvil.type terra -git config drs.remote.anvil.endpoint https://data.terra.bio -git config drs.remote.anvil.auth google-adc -git config drs.remote.anvil.mode read-only +git drs remote add anvil terra --scope my-billing-project/my-workspace git drs ping anvil ``` Terra credential configuration: -- `drs.remote..auth` records the credential source that the Terra resolver should use. The currently documented mode is `google-adc`, which means Application Default Credentials from the local Google Cloud environment. -- Do not store Google access tokens, refresh tokens, service-account JSON, or other secrets in repo-local `git config`. The Terra remote stores only the endpoint, credential-source name, and mode. +- The `terra` preset selects `google-adc`, which means Application Default Credentials from the local Google Cloud environment. +- Do not put Google access tokens, refresh tokens, service-account JSON, or other secrets in repo-local Git configuration or in `--credential`. - Configure ADC outside `git-drs`, for example with `gcloud auth application-default login` for an interactive user credential or by setting `GOOGLE_APPLICATION_CREDENTIALS` to a service-account key path in automation. -- At runtime, `git-drs` loads the Terra remote from `drs.remote..*`, records the Terra endpoint in the remote context, and Terra-aware resolution/download code should obtain Google credentials from the configured source and attach them to outbound Terra/DRSHub/DRS requests as bearer tokens. The credential material is therefore passed through the process environment or ADC provider chain, not through committed repository files. -- `git drs ping` validates the Terra DRS service-info endpoint and prints `auth: none` today because service-info is checked without creating a Syfon bearer/basic credential. Future Terra resolver operations should still honor `drs.remote..auth` when making authenticated object-resolution or access-url requests. +- At runtime, Terra-aware resolution obtains Google credentials from the ADC provider chain; credential material is not written to committed repository files. +- `git drs ping` checks the public DRS service-info endpoint without sending the ADC credential. Authenticated object resolution and access requests still use ADC. A successful Terra ping includes: @@ -154,9 +138,8 @@ health: ok Troubleshooting: -- `no remote configuration found`: run `git drs remote list` and pass an existing remote name, or configure the Terra remote with the `git config` commands above. -- `terra endpoint is empty`: set `drs.remote..endpoint` to the Terra DRS base URL, for example `https://data.terra.bio` for the production Terra Data Repository DRS service. -- `terra endpoint must be an absolute URL`: include the URL scheme, for example `https://data.terra.bio` instead of `data.terra.bio`. +- `no remote configuration found`: run `git drs remote list` and pass an existing remote name, or add one with `git drs remote add anvil terra`. +- endpoint errors: remove the remote and add it again with the `terra` preset or a complete HTTPS URL. - `terra DRS service-info returned ...`: verify the server is up and that the base endpoint is correct. You can test the exact URL with `curl -i https://data.terra.bio/ga4gh/drs/v1/service-info`. - network, DNS, or TLS errors: check VPN/proxy/firewall settings and retry with `GIT_CURL_VERBOSE=1 git drs ping ` for additional HTTP diagnostics from Git-adjacent workflows. diff --git a/docs/getting-started.md b/docs/getting-started.md index da9d724e..dcf0a698 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,20 +16,39 @@ The most important distinction is: - `git pull` updates commits and checkout state - `git drs pull` hydrates tracked pointer files already present in the checkout -## The Setup Command +## Connect A Remote -The standard setup command is: +List and inspect the presets shipped with the current release: ```bash -git drs remote add gen3 production --cred ~/.gen3/credentials.json +git drs preset list +git drs preset show calypr ``` -That command: +Then add a named remote from a preset. For example, a scoped Calypr/Gen3 +remote using a credential file is: -- stores the remote configuration -- imports or refreshes credentials +```bash +git drs remote add production calypr --scope \ + --credential file:~/.gen3/credentials.json +``` + +This command: + +- expands the preset into a pinned endpoint, provider, authentication method, + and preset catalog version +- stores only the credential source, not an inline secret - bootstraps repo-local `git-drs` wiring when it is missing +The built-in presets are `calypr`, `terra`, `synapse`, and `cgc`. The local +remote name is optional: `git drs remote add calypr ...` derives the name +`calypr`. You can also connect an unlisted HTTPS endpoint directly: + +```bash +git drs remote add research https://drs.example.org \ + --provider ga4gh --auth none +``` + ## The Two Common Workflows ### Existing Repository @@ -37,7 +56,8 @@ That command: ```bash git clone cd -git drs remote add gen3 production --cred ~/.gen3/credentials.json +git drs remote add production calypr --scope \ + --credential file:~/.gen3/credentials.json git drs pull ``` @@ -47,7 +67,8 @@ git drs pull mkdir my-data-repo cd my-data-repo git init -git drs remote add gen3 production --cred ~/.gen3/credentials.json +git drs remote add production calypr --scope \ + --credential file:~/.gen3/credentials.json git drs track "*.bam" git add .gitattributes git commit -m "Configure tracked files" @@ -127,12 +148,18 @@ git drs push That is the supported delete flow for tracked `git-drs` objects. For the fuller decision tree, see [Removing Files](remove-files.md). -### Refresh credentials +### Change a credential source ```bash -git drs remote add gen3 production --cred /path/to/new-credentials.json +git drs remote remove production +git drs remote add production calypr --scope \ + --credential file:/path/to/new-credentials.json ``` +The unified command refuses to overwrite an existing remote. Remove and add it +again when its endpoint, preset, or credential source must change. Prefer a +refreshing helper or profile source when the provider supports one. + ## Read Next - [Commands Reference](commands.md) for exact command syntax diff --git a/docs/quickstart.md b/docs/quickstart.md index f8411aed..ce40be96 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -56,9 +56,21 @@ Verify: git-drs version ``` -## 2. Get Credentials +## 2. Choose A Preset And Credential Source -Download your Gen3 API credentials JSON from your commons profile page and save it somewhere stable, for example: +See the non-secret server defaults included with your installed release: + +```bash +git drs preset list +git drs preset show calypr +``` + +The built-in aliases are `calypr`, `terra`, `synapse`, and `cgc`. Presets supply +the endpoint, provider adapter, and usual authentication method; they never +contain credentials. + +For the Calypr/Gen3 workflow below, download your Gen3 API credentials JSON +from your commons profile page and save it somewhere stable, for example: ```bash ~/.gen3/credentials.json @@ -72,12 +84,18 @@ Typical flow: 4. Download the JSON file. 5. Save it somewhere stable. +On the command line, refer to the file as `file:~/.gen3/credentials.json`. +Other supported credential sources are `env:VARIABLE`, `helper:NAME`, +`profile:NAME`, and `stdin`. Do not put a token or password directly in +`--credential`. + ## 3. Connect An Existing Repository ```bash git clone cd -git drs remote add gen3 production --cred ~/.gen3/credentials.json +git drs remote add production calypr --scope \ + --credential file:~/.gen3/credentials.json git drs pull ``` @@ -89,12 +107,17 @@ Use this path when the repository already contains tracked pointers and you want mkdir my-data-repo cd my-data-repo git init -git drs remote add gen3 production --cred ~/.gen3/credentials.json +git drs remote add production calypr --scope \ + --credential file:~/.gen3/credentials.json git drs track "*.bam" git add .gitattributes git commit -m "Configure tracked files" ``` +`production` is the local remote name and `calypr` is the preset. If the preset +name is also a suitable local name, the shorter form is +`git drs remote add calypr ...`. + ## 5. Day-One Commands Update Git history: From 16d5e9102b8d6c1bca425264cbc1a2af35c887f4 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 27 Jul 2026 10:15:57 -0700 Subject: [PATCH 42/66] listed preset includes auth type --- cmd/preset/root.go | 2 +- cmd/preset/root_test.go | 37 ++++++++++++++++++++++++++++++++ docs/commands.md | 6 +++--- internal/presets/presets.go | 15 ++++++++++++- internal/presets/presets_test.go | 21 +++++++++++++++++- 5 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 cmd/preset/root_test.go diff --git a/cmd/preset/root.go b/cmd/preset/root.go index 9ae0d531..47cca59c 100644 --- a/cmd/preset/root.go +++ b/cmd/preset/root.go @@ -17,7 +17,7 @@ var listCmd = &cobra.Command{Use: "list", Short: "List built-in presets", Args: } sort.Slice(items, func(i, j int) bool { return items[i].Alias < items[j].Alias }) for _, p := range items { - fmt.Fprintf(cmd.OutOrStdout(), "%-10s %-8s %s built-in v%d\n", p.Alias, p.Provider, p.Endpoint, presets.CatalogVersion) + fmt.Fprintf(cmd.OutOrStdout(), "%-10s %-8s %-30s %s built-in v%d\n", p.Alias, p.Provider, p.Auth, p.Endpoint, presets.CatalogVersion) } return nil }} diff --git a/cmd/preset/root_test.go b/cmd/preset/root_test.go new file mode 100644 index 00000000..df7019c2 --- /dev/null +++ b/cmd/preset/root_test.go @@ -0,0 +1,37 @@ +package preset + +import ( + "bytes" + "strings" + "testing" +) + +func TestListIncludesAuthenticationType(t *testing.T) { + var out bytes.Buffer + listCmd.SetOut(&out) + t.Cleanup(func() { listCmd.SetOut(nil) }) + + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + "calypr gen3 provider-helper:gen3-profile", + "cgc cgc bearer", + "synapse synapse bearer", + "terra terra google-adc", + } { + if fields := strings.Fields(out.String()); !containsConsecutive(fields, strings.Fields(want)) { + t.Errorf("preset list output does not include %q:\n%s", want, out.String()) + } + } +} + +func containsConsecutive(values, want []string) bool { + for i := 0; i+len(want) <= len(values); i++ { + if strings.Join(values[i:i+len(want)], " ") == strings.Join(want, " ") { + return true + } + } + return false +} diff --git a/docs/commands.md b/docs/commands.md index 369ac9eb..91e2783a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -72,9 +72,9 @@ compatibility forms. New scripts should use the unified command. ### `git drs preset list` / `git drs preset show ` Display the presets embedded in this release. `list` shows each alias, -provider, endpoint, and catalog version. `show` also displays its authentication -default and, when present, registry service ID. Presets contain no credential -or secret values. +provider, authentication default, endpoint, and catalog version. `show` displays +the same details vertically and, when present, the registry service ID. Presets +contain no credential or secret values. | Alias | Provider | Authentication default | | --- | --- | --- | diff --git a/internal/presets/presets.go b/internal/presets/presets.go index fcc0b9db..891ddb44 100644 --- a/internal/presets/presets.go +++ b/internal/presets/presets.go @@ -29,13 +29,26 @@ type catalog struct { var builtIn []byte func List() ([]Preset, error) { + return loadCatalog(builtIn) +} + +func loadCatalog(data []byte) ([]Preset, error) { var c catalog - if err := yaml.Unmarshal(builtIn, &c); err != nil { + if err := yaml.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("decode built-in preset catalog: %w", err) } if c.Version != CatalogVersion { return nil, fmt.Errorf("unsupported built-in preset catalog version %d", c.Version) } + for i, preset := range c.Presets { + if strings.TrimSpace(preset.Auth) == "" { + name := strings.TrimSpace(preset.Alias) + if name == "" { + name = fmt.Sprintf("at index %d", i) + } + return nil, fmt.Errorf("preset %q does not specify an authentication type", name) + } + } return append([]Preset(nil), c.Presets...), nil } diff --git a/internal/presets/presets_test.go b/internal/presets/presets_test.go index af58f419..a58c118f 100644 --- a/internal/presets/presets_test.go +++ b/internal/presets/presets_test.go @@ -1,6 +1,9 @@ package presets -import "testing" +import ( + "strings" + "testing" +) func TestBuiltInCatalog(t *testing.T) { items, err := List() @@ -17,3 +20,19 @@ func TestBuiltInCatalog(t *testing.T) { } } } + +func TestCatalogRequiresPresetAuthenticationType(t *testing.T) { + _, err := loadCatalog([]byte(` +version: 1 +presets: + - alias: unauthenticated + endpoint: https://drs.example.org + provider: ga4gh +`)) + if err == nil { + t.Fatal("expected a preset without an authentication type to be rejected") + } + if !strings.Contains(err.Error(), `preset "unauthenticated" does not specify an authentication type`) { + t.Fatalf("unexpected error: %v", err) + } +} From 55ac90a8d7adb912eeb2261f86dbdbd4fa962da6 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 27 Jul 2026 15:18:30 -0700 Subject: [PATCH 43/66] Complete generic remote runtime boundary --- README.md | 2 +- cmd/addref/add-ref.go | 19 ++-- cmd/ping/main.go | 13 +-- cmd/pull/main.go | 15 ++-- cmd/pull/pull_test.go | 13 --- cmd/push/main.go | 14 +-- docs/anvil-terra-poc-presentation.md | 17 ++-- docs/anvil-terra-poc.md | 66 +++++--------- docs/anvil.md | 26 ++---- docs/commands.md | 4 + docs/getting-started.md | 4 +- docs/quickstart.md | 4 +- internal/config/config.go | 89 ------------------- internal/config/config_test.go | 18 ++++ internal/config/repository_config_test.go | 51 ----------- .../config/terra_remote_acceptance_test.go | 36 -------- internal/remoteruntime/runtime.go | 38 ++++++-- internal/remoteruntime/runtime_test.go | 26 ++++++ 18 files changed, 141 insertions(+), 314 deletions(-) delete mode 100644 internal/config/repository_config_test.go delete mode 100644 internal/config/terra_remote_acceptance_test.go diff --git a/README.md b/README.md index 16cf9056..3a51ed6c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Push and pull depend on server-side bucket mapping for the requested scope. That | --- | --- | | `git drs install` | Install global `git-drs` filter config | | `git drs init` | Explicitly initialize or repair repository-local `git-drs` state | -| `git drs remote add ` | Add a DRS remote (built-ins: calypr, terra, synapse, cgc) | +| `git drs remote add ` | Add a DRS remote (operational: Calypr/Gen3 and Terra; catalog-only: Synapse and CGC) | | `git drs preset list` | List the non-secret presets embedded in this release | | `git drs remote list` | List configured remotes | | `git drs remote remove ` | Remove a configured DRS remote | diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 86e271ed..c7cecb80 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -68,17 +68,6 @@ var Cmd = &cobra.Command{ if selected == nil { return fmt.Errorf("remote %q is not configured", remoteName) } - if remoteType != "" { - actual := "gen3" - if _, ok := cfg.Remotes[remoteName]; ok && cfg.Remotes[remoteName].Terra != nil { - actual = "terra" - } else if cfg.Remotes[remoteName].Local != nil { - actual = "local" - } - if remoteType != actual { - return fmt.Errorf("--remote-type %q conflicts with configured remote %q type %q", remoteType, remoteName, actual) - } - } dstPath, err = safeDestination(dstPath) if err != nil { return err @@ -88,6 +77,12 @@ var Cmd = &cobra.Command{ if err != nil { return err } + if !client.CanResolve() { + return fmt.Errorf("remote %q cannot resolve DRS objects", remoteName) + } + if remoteType != "" && remoteType != string(client.RemoteType) { + return fmt.Errorf("--remote-type %q conflicts with configured remote %q type %q", remoteType, remoteName, client.RemoteType) + } obj, err := resolveAddRefObject(context.Background(), cfg, remoteName, client, drsUri) if err != nil { @@ -105,7 +100,7 @@ var Cmd = &cobra.Command{ } oid := addRefLocalOID(drsUri, remoteName, &obj) - if client.RemoteType == config.TerraServerType { + if client.IsReadOnly() { if err := lfs.CreateDRSPointer(&obj, dstPath, drsUri); err != nil { return err } diff --git a/cmd/ping/main.go b/cmd/ping/main.go index 72625975..d17a4760 100644 --- a/cmd/ping/main.go +++ b/cmd/ping/main.go @@ -35,7 +35,7 @@ type healthInfo struct { } var pingHealth = func(ctx context.Context, gc *remoteruntime.GitContext) (healthInfo, error) { - if gc != nil && gc.RemoteType == config.TerraServerType { + if gc != nil && gc.IsReadOnly() { serviceInfo, err := pingTerraServiceInfo(ctx, gc.Endpoint) return healthInfo{ServiceInfo: serviceInfo}, err } @@ -136,16 +136,7 @@ func resolveStatus(args []string, logger *slog.Logger) (statusInfo, *remoterunti Bucket: gc.BucketName, StoragePrefix: gc.StoragePrefix, AuthMode: authMode(gc), - } - switch remoteCfg.(type) { - case *config.Gen3Remote: - status.RemoteType = string(config.Gen3ServerType) - case *config.LocalRemote: - status.RemoteType = string(config.LocalServerType) - case *config.TerraRemote: - status.RemoteType = string(config.TerraServerType) - default: - status.RemoteType = "unknown" + RemoteType: string(gc.RemoteType), } return status, gc, nil diff --git a/cmd/pull/main.go b/cmd/pull/main.go index c2e01c0b..b69e41b8 100644 --- a/cmd/pull/main.go +++ b/cmd/pull/main.go @@ -95,7 +95,10 @@ var Cmd = &cobra.Command{ return err } var anvil resolver.Resolver - if drsCtx.RemoteType == config.TerraServerType { + if !drsCtx.CanDownload() || !drsCtx.CanResolve() { + return fmt.Errorf("remote %q does not support resolving and downloading DRS objects", remote) + } + if drsCtx.IsReadOnly() { anvil, err = resolver.NewAnVIL(cmd.Context(), drsCtx.Endpoint) if err != nil { return err @@ -218,7 +221,7 @@ var Cmd = &cobra.Command{ logg.Debug("no missing pointer objects to download") } - readOnly := remoteIsReadOnly(cfg, remote) + readOnly := drsCtx.IsReadOnly() if err := checkoutDownloadedFiles(pointers, progress, readOnly); err != nil { return err } @@ -440,14 +443,6 @@ func globToRegexp(pattern string) string { return b.String() } -func remoteIsReadOnly(cfg *config.Config, remote config.Remote) bool { - selected, ok := cfg.Remotes[remote] - // Terra remotes are resolver-only and therefore always read-only. Match the - // push command's capability check rather than relying on optional legacy - // configuration fields being present. - return ok && selected.Terra != nil -} - func checkoutDownloadedFiles(files []pointerFile, progress *internaltransfer.PullProgressRenderer, readOnly bool) error { for _, f := range files { if strings.TrimSpace(f.Name) == "" || strings.TrimSpace(f.Oid) == "" { diff --git a/cmd/pull/pull_test.go b/cmd/pull/pull_test.go index eeab5df6..fb45fb11 100644 --- a/cmd/pull/pull_test.go +++ b/cmd/pull/pull_test.go @@ -361,19 +361,6 @@ func TestCheckoutDownloadedFilesFromReadOnlyRemoteSetsReadOnlyPermission(t *test } } -func TestRemoteIsReadOnly(t *testing.T) { - cfg := &config.Config{Remotes: map[config.Remote]config.RemoteSelect{ - "anvil": {Terra: &config.TerraRemote{Mode: "read-only"}}, - "local": {Local: &config.LocalRemote{}}, - }} - if !remoteIsReadOnly(cfg, "anvil") { - t.Fatal("expected read-only Terra remote to be detected") - } - if remoteIsReadOnly(cfg, "local") { - t.Fatal("did not expect local remote to be read-only") - } -} - func TestRefreshGitIndexForHydratedFilesClearsDirtyStatus(t *testing.T) { repo := t.TempDir() gitDRS := buildGitDRSBinaryForTest(t) diff --git a/cmd/push/main.go b/cmd/push/main.go index e61aa8a3..afabd710 100644 --- a/cmd/push/main.go +++ b/cmd/push/main.go @@ -58,20 +58,20 @@ var Cmd = &cobra.Command{ return err } } - if selected, ok := cfg.Remotes[remote]; ok && selected.Terra != nil { + drsClient, err := remoteruntime.New(cfg, remote, myLogger) + if err != nil { + myLogger.DebugContext(ctx, "create remote client failed", "error", err) + return err + } + if drsClient.IsReadOnly() || !drsClient.CanUpload() || !drsClient.CanRegister() { return fmt.Errorf( - "remote %q is read-only: git drs push cannot upload files to Terra\n"+ + "remote %q is read-only: git drs push cannot upload or register files\n"+ "no files were uploaded, and you do not need to back out a commit that references existing Terra data\n"+ "to publish the commit and its DRS references, use ordinary git push to a Git remote; this pushes only Git metadata and does not upload files to Terra", remote, ) } - drsClient, err := remoteruntime.New(cfg, remote, myLogger) - if err != nil { - myLogger.DebugContext(ctx, "create remote client failed", "error", err) - return err - } drsClient.ForceUpload = pushForceUpload state, err := resolveSyncRefState(ctx, string(remote)) if err != nil { diff --git a/docs/anvil-terra-poc-presentation.md b/docs/anvil-terra-poc-presentation.md index 3de78a23..915099e4 100644 --- a/docs/anvil-terra-poc-presentation.md +++ b/docs/anvil-terra-poc-presentation.md @@ -217,20 +217,13 @@ The DRS URI stays canonical. A checksum describes content; it does not identify
-### Tracked `.git-drs/config.yaml` - -```yaml -version: 1 -default_remote: anvil -remotes: - anvil: - type: terra - endpoint: https://data.terra.bio - auth: google-adc - mode: read-only +### Repository-local Git configuration + +```bash +git drs remote add anvil terra --checkout hydrate ``` -Only allowlisted public settings load. Secret-like or unknown sensitive fields fail closed. +Remote metadata has one authoritative representation in `.git/config`; credentials remain in the provider store.
diff --git a/docs/anvil-terra-poc.md b/docs/anvil-terra-poc.md index 2de8e546..55fbfcb8 100644 --- a/docs/anvil-terra-poc.md +++ b/docs/anvil-terra-poc.md @@ -85,20 +85,13 @@ Both users need: - a Google identity authorized for the referenced AnVIL data; - working Google ADC, initially established with the supported Google authentication tooling. -The repository contains a non-secret `.git-drs/config.yaml` file: - -```yaml -version: 1 -default_remote: anvil -remotes: - anvil: - type: terra - endpoint: https://data.terra.bio - auth: google-adc - mode: read-only +Each clone configures its remote in the repository-local Git config: + +```bash +git drs remote add anvil terra --checkout hydrate ``` -Local Git configuration may override repository configuration, but secrets are never permitted in `.git-drs/config.yaml`. +Repository-local Git config is authoritative. It is not tracked, and credential material remains in the provider credential store. ### First user: add and publish references @@ -115,7 +108,7 @@ git drs add-ref --remote anvil \ git drs add-ref --remote anvil \ drs:/// data/sample-2.cram -git add .git-drs/config.yaml .gitattributes data/ +git add .gitattributes data/ git commit -m "Add AnVIL data references" git push ``` @@ -124,7 +117,7 @@ For multiple objects, the preferred POC workflow is a manifest: ```bash git drs add-ref --remote anvil --manifest references.tsv -git add .git-drs/config.yaml .gitattributes data/ +git add .gitattributes data/ git commit -m "Add AnVIL data references" git push ``` @@ -376,40 +369,28 @@ The implementation must: The batch operation is a wrapper over the same single-reference primitive, not an independent resolution system. -### Phase 8: Commit non-secret repository configuration +### Phase 8: Configure each clone -Adopt Option B: commit `.git-drs/config.yaml` so a clone carries the public information required to select the correct resolver. +Use the remote command to write the single authoritative repository-local Git configuration after cloning. Initial schema: -```yaml -version: 1 -default_remote: anvil -remotes: - anvil: - type: terra - endpoint: https://data.terra.bio - auth: google-adc - mode: read-only +```bash +git drs remote add anvil terra --checkout hydrate ``` Configuration rules: -- only an allowlisted schema may be loaded from tracked configuration; -- credentials, tokens, arbitrary headers, signed URLs, and local paths are forbidden; -- local Git configuration may override public endpoint or remote selection; -- secret-bearing configuration remains local and takes precedence; -- commands must identify whether an effective setting came from repository or local configuration; -- invalid or secret-like tracked fields must fail closed with an actionable error. - -With this configuration committed, the second user's desired flow is reduced to authentication, clone, and pull. +- Git config is the only remote configuration representation; +- credentials remain in environment variables or provider credential stores; +- every clone runs `git drs remote add` before pulling. ## Git Push Semantics for This Prototype AnVIL data already exists and the configured Terra remote is read-only. Publishing references therefore uses ordinary Git: ```bash -git add .git-drs/config.yaml .gitattributes data/ +git add .gitattributes data/ git commit -m "Add AnVIL data references" git push ``` @@ -419,7 +400,6 @@ git push - pointer files; - repository paths and Git history; - `.gitattributes` rules; -- non-secret `.git-drs/config.yaml`; - an optional reference manifest when the repository chooses to track it. It does not: @@ -516,12 +496,11 @@ Verify that: ### 11. Repository configuration safety -Verify that tracked configuration: +Verify that repository-local Git configuration: -- loads after a fresh clone; -- can be safely overridden locally; -- rejects secret-bearing or unknown sensitive fields; -- never shadows explicit local credentials with repository-controlled secrets. +- is created by `git drs remote add` after a fresh clone; +- is the only source of remote metadata; +- contains credential source identifiers but never credential values. ## Backlog in Priority Order @@ -535,7 +514,7 @@ Verify that tracked configuration: 6. Add explicit DRS URI, cache OID, and content checksum identities. 7. Refactor pull to use `AnVILResolver` rather than a Syfon client. 8. Validate size and SHA256 and atomically manage cache content. -9. Implement and safely load `.git-drs/config.yaml`. +9. Implement and safely load repository-local Git configuration. 10. Reject `git drs push` for read-only Terra remotes. 11. Add a real or contract-faithful two-user clone-and-pull acceptance test. @@ -546,7 +525,7 @@ Verify that tracked configuration: 3. Add safe retry, cancellation, and expired-URL re-resolution. 4. Add `git drs doctor` checks for ADC, configuration, endpoint health, resolution, authorization, and pointer validity. 5. Improve errors for absent credentials, denied access, missing records, invalid manifests, and integrity failures. -6. Add CI coverage for clean-clone portability, authorization isolation, and repository configuration safety. +6. Add CI coverage for clean-clone setup, authorization isolation, and repository configuration safety. 7. Publish versioned binaries and a compatibility matrix for the tested AnVIL contract. ### P2: explicitly deferred @@ -568,7 +547,7 @@ The AnVIL/Terra reference POC is functional when: - Each generated pointer commits the normalized canonical DRS URI. - Pointers also preserve expected size and SHA256 when available. - The Git repository contains no payload bytes, credentials, authorization headers, or signed URLs. -- The repository commits a validated, non-secret `.git-drs/config.yaml` selecting the read-only AnVIL remote. +- Each clone configures the read-only AnVIL remote in repository-local Git configuration. - Ordinary `git push` publishes the references without contacting AnVIL. - `git drs push` refuses to operate on the read-only Terra remote. - User B can clone without receiving any User A clone-local metadata, cache, or credentials. @@ -579,4 +558,3 @@ The AnVIL/Terra reference POC is functional when: - Interrupted, expired-URL, and integrity-failure paths do not poison the cache or overwrite pointers with invalid content. - Repeated pulls are idempotent and reuse verified cache entries. - The complete two-user flow runs as a documented acceptance test in a clean environment. - diff --git a/docs/anvil.md b/docs/anvil.md index 36a30f44..6795e8ce 100644 --- a/docs/anvil.md +++ b/docs/anvil.md @@ -19,24 +19,13 @@ ADC is local user state. Never copy the ADC JSON file into the repository. ## Configure an AnVIL repository -Create and commit `.git-drs/config.yaml`: - -```yaml -version: 1 -default_remote: anvil -remotes: - anvil: - type: terra - endpoint: https://data.terra.bio - auth: google-adc - mode: read-only +Configure the remote in repository-local Git config: + +```bash +git drs remote add anvil terra --checkout hydrate ``` -The tracked schema intentionally permits only the public Terra endpoint, -Google ADC authentication selection, and read-only mode. Tokens, arbitrary -headers, credential paths, signed URLs, unknown fields, non-HTTPS endpoints, -and embedded URL credentials are rejected. Clone-local Git configuration can -override public settings when an organization uses another trusted resolver. +The command stores only public remote metadata in `.git/config`. Google ADC remains local user state. Each clone must run the command because `.git/config` is not tracked. ## Publish one reference @@ -46,7 +35,7 @@ Use the configured remote and choose the path that the data should occupy: git drs add-ref --remote anvil \ drs:/// data/sample.cram # Run this only after add-ref finishes successfully. -git add .git-drs/config.yaml .gitattributes data/sample.cram +git add .gitattributes data/sample.cram git commit -m "Reference AnVIL sample" git push ``` @@ -140,9 +129,6 @@ references. Do not use `git drs push` for a Terra remote. access to the controlled dataset. Do not ask another user to share ADC files. * `AnVIL DRS object not found`: verify the committed URI and that the configured resolver supports its authority. -* Repository configuration errors are intentionally strict. Remove secret or - unknown fields from `.git-drs/config.yaml`; place supported local overrides - in Git configuration instead. * Do not commit `.git/drs`, `.git/lfs`, Google credential files, bearer tokens, request headers, or resolved access URLs. Access URLs are temporary and are resolved again at download time. diff --git a/docs/commands.md b/docs/commands.md index 91e2783a..b4b5ccd8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -41,6 +41,10 @@ Add a DRS server with the unified remote command. The built-in aliases are `calypr`, `terra`, `synapse`, and `cgc`; inspect their non-secret defaults with `git drs preset list` or `git drs preset show `. +Calypr/Gen3 and Terra currently have operational runtime adapters. Synapse and +CGC are catalog-only; execution commands reject them explicitly rather than +constructing a partially authenticated client. + ```bash git drs remote add cgc --credential env:CGC_TOKEN git drs remote add synapse --credential helper:synapse diff --git a/docs/getting-started.md b/docs/getting-started.md index dcf0a698..c36d3c37 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,7 +40,9 @@ This command: - stores only the credential source, not an inline secret - bootstraps repo-local `git-drs` wiring when it is missing -The built-in presets are `calypr`, `terra`, `synapse`, and `cgc`. The local +The built-in presets are `calypr`, `terra`, `synapse`, and `cgc`. Calypr/Gen3 +and Terra have operational runtime adapters; Synapse and CGC are catalog-only +and execution commands reject them until their adapters are implemented. The local remote name is optional: `git drs remote add calypr ...` derives the name `calypr`. You can also connect an unlisted HTTPS endpoint directly: diff --git a/docs/quickstart.md b/docs/quickstart.md index ce40be96..97074351 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -65,7 +65,9 @@ git drs preset list git drs preset show calypr ``` -The built-in aliases are `calypr`, `terra`, `synapse`, and `cgc`. Presets supply +The built-in aliases are `calypr`, `terra`, `synapse`, and `cgc`. Only +Calypr/Gen3 and Terra currently have operational runtime adapters; Synapse and +CGC are catalog-only presets. Presets supply the endpoint, provider adapter, and usual authentication method; they never contain credentials. diff --git a/internal/config/config.go b/internal/config/config.go index 9acaf783..24a95a27 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,18 +3,13 @@ package config import ( "errors" "fmt" - "io" - "net/url" - "os" "os/exec" - "path/filepath" "sort" "strconv" "strings" "github.com/calypr/git-drs/internal/gitrepo" "github.com/go-git/go-git/v5" - "gopkg.in/yaml.v3" ) // RemoteType represents the type of server being initialized @@ -106,10 +101,6 @@ func getRepo() (*git.Repository, error) { return gitrepo.GetRepo() } -func (c Config) ConfigPath() (string, error) { - return getConfigPath() -} - // updates and git adds a Git DRS config file // this should handle three cases: // 1. create a new config file if it does not exist / is empty @@ -319,10 +310,6 @@ func LoadConfig() (*Config, error) { cfg := &Config{ Remotes: make(map[Remote]RemoteSelect), } - if err := loadRepositoryConfig(cfg); err != nil { - return nil, err - } - // Iterate over all sections to find 'drs' and its subsections for _, section := range conf.Raw.Sections { if section.Name != configSection { @@ -463,79 +450,3 @@ func firstRemote(cfg *Config) Remote { sort.Strings(names) return Remote(names[0]) } - -// GetGitConfigInt reads an integer value from git config -// getGitConfigValue retrieves a value from git config by key -func getConfigPath() (string, error) { - topLevel, err := gitrepo.GitTopLevel() - if err != nil { - return "", err - } - - configPath := filepath.Join(topLevel, gitrepo.RepoDRSDir, gitrepo.ConfigYAML) - return configPath, nil -} - -type repositoryConfig struct { - Version int `yaml:"version"` - DefaultRemote string `yaml:"default_remote"` - Remotes map[string]repositoryRemoteConfig `yaml:"remotes"` -} - -type repositoryRemoteConfig struct { - Type string `yaml:"type"` - Endpoint string `yaml:"endpoint"` - Auth string `yaml:"auth"` - Mode string `yaml:"mode"` -} - -// loadRepositoryConfig loads only the public, allowlisted clone-portable -// configuration. KnownFields deliberately fails closed for tokens, headers, -// credential paths, and future fields until they receive a security review. -func loadRepositoryConfig(cfg *Config) error { - path, err := getConfigPath() - if err != nil { - return err - } - f, err := os.Open(path) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("open repository git-drs config: %w", err) - } - defer f.Close() - var raw repositoryConfig - dec := yaml.NewDecoder(io.LimitReader(f, 1<<20)) - dec.KnownFields(true) - if err := dec.Decode(&raw); err != nil { - return fmt.Errorf("invalid repository config %s: %w", path, err) - } - if raw.Version != 1 { - return fmt.Errorf("invalid repository config %s: unsupported version %d", path, raw.Version) - } - if strings.TrimSpace(raw.DefaultRemote) != "" { - cfg.DefaultRemote = Remote(strings.TrimSpace(raw.DefaultRemote)) - } - for name, r := range raw.Remotes { - name = strings.TrimSpace(name) - if name == "" { - return fmt.Errorf("invalid repository config %s: remote name cannot be empty", path) - } - if r.Type != "terra" { - return fmt.Errorf("invalid repository config %s: remote %q type must be terra", path, name) - } - if r.Auth != "google-adc" { - return fmt.Errorf("invalid repository config %s: remote %q auth must be google-adc", path, name) - } - if r.Mode != "read-only" { - return fmt.Errorf("invalid repository config %s: remote %q mode must be read-only", path, name) - } - u, err := url.ParseRequestURI(strings.TrimSpace(r.Endpoint)) - if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { - return fmt.Errorf("invalid repository config %s: remote %q endpoint must be an HTTPS URL without credentials", path, name) - } - cfg.Remotes[Remote(name)] = RemoteSelect{Terra: &TerraRemote{Endpoint: u.String(), Auth: r.Auth, Mode: r.Mode}} - } - return nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 33d8cf50..739135f5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,9 +3,27 @@ package config import ( "os" "os/exec" + "path/filepath" "testing" ) +func TestLoadConfigIgnoresFormerRepositoryYAMLPath(t *testing.T) { + dir := setupTestRepo(t) + if err := os.MkdirAll(filepath.Join(dir, ".git-drs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".git-drs", "config.yaml"), []byte("not: [valid"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("former repository YAML path must not be loaded: %v", err) + } + if len(cfg.Remotes) != 0 { + t.Fatalf("unexpected remotes loaded from former YAML path: %+v", cfg.Remotes) + } +} + func setupTestRepo(t *testing.T) string { t.Helper() diff --git a/internal/config/repository_config_test.go b/internal/config/repository_config_test.go deleted file mode 100644 index 212dc46e..00000000 --- a/internal/config/repository_config_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRepositoryConfigLoadsAndLocalGitOverrides(t *testing.T) { - repo := setupTestRepo(t) - dir := filepath.Join(repo, ".git-drs") - if err := os.Mkdir(dir, 0o755); err != nil { - t.Fatal(err) - } - data := "version: 1\ndefault_remote: anvil\nremotes:\n anvil:\n type: terra\n endpoint: https://data.terra.bio\n auth: google-adc\n mode: read-only\n" - if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(data), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(repo, ".git/config"), append(mustRead(t, filepath.Join(repo, ".git/config")), []byte("\n[drs]\n\tdefault-remote = local-choice\n")...), 0o644); err != nil { - t.Fatal(err) - } - cfg, err := LoadConfig() - if err != nil { - t.Fatal(err) - } - if cfg.GetRemote("anvil") == nil || cfg.DefaultRemote != "local-choice" { - t.Fatalf("repository remote or local override missing: %+v", cfg) - } -} - -func TestRepositoryConfigRejectsSecretFields(t *testing.T) { - repo := setupTestRepo(t) - dir := filepath.Join(repo, ".git-drs") - _ = os.Mkdir(dir, 0o755) - data := "version: 1\nremotes:\n anvil:\n type: terra\n endpoint: https://data.terra.bio\n auth: google-adc\n mode: read-only\n token: do-not-load\n" - _ = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(data), 0o644) - _, err := LoadConfig() - if err == nil || !strings.Contains(err.Error(), "field token not found") { - t.Fatalf("expected fail-closed secret rejection, got %v", err) - } -} - -func mustRead(t *testing.T, path string) []byte { - t.Helper() - b, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return b -} diff --git a/internal/config/terra_remote_acceptance_test.go b/internal/config/terra_remote_acceptance_test.go deleted file mode 100644 index e5a6ea81..00000000 --- a/internal/config/terra_remote_acceptance_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -import ( - "os/exec" - "testing" -) - -func TestAcceptanceLoadTerraRemoteConfig(t *testing.T) { - repo := setupTestRepo(t) - runGitConfig := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"config"}, args...)...) - cmd.Dir = repo - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git config %v failed: %v: %s", args, err, string(out)) - } - } - - runGitConfig("drs.default-remote", "anvil") - runGitConfig("drs.remote.anvil.type", "terra") - runGitConfig("drs.remote.anvil.endpoint", "https://data.terra.bio") - runGitConfig("drs.remote.anvil.auth", "google-adc") - runGitConfig("drs.remote.anvil.mode", "read-only") - - cfg, err := LoadConfig() - if err != nil { - t.Fatalf("LoadConfig error: %v", err) - } - remote := cfg.GetRemote(Remote("anvil")) - if remote == nil { - t.Fatalf("expected terra remote to load as a DRS remote") - } - if got := remote.GetEndpoint(); got != "https://data.terra.bio" { - t.Fatalf("unexpected terra endpoint: %q", got) - } -} diff --git a/internal/remoteruntime/runtime.go b/internal/remoteruntime/runtime.go index baf8b298..d6fd3f55 100644 --- a/internal/remoteruntime/runtime.go +++ b/internal/remoteruntime/runtime.go @@ -32,8 +32,21 @@ type GitContext struct { UploadConcurrency int Logger *slog.Logger Credential *syconf.Credential + Capabilities Capabilities } +// Capabilities is the command-facing contract for a resolved remote. Commands +// must use this contract rather than infer behaviour from configuration types. +type Capabilities struct { + Resolve, Download, Upload, Register, ReadOnly bool +} + +func (g *GitContext) CanResolve() bool { return g != nil && g.Capabilities.Resolve } +func (g *GitContext) CanDownload() bool { return g != nil && g.Capabilities.Download } +func (g *GitContext) CanUpload() bool { return g != nil && g.Capabilities.Upload } +func (g *GitContext) CanRegister() bool { return g != nil && g.Capabilities.Register } +func (g *GitContext) IsReadOnly() bool { return g != nil && g.Capabilities.ReadOnly } + func New(cfg *config.Config, remote config.Remote, logger *slog.Logger) (*GitContext, error) { x, ok := cfg.Remotes[remote] if !ok { @@ -53,10 +66,20 @@ func New(cfg *config.Config, remote config.Remote, logger *slog.Logger) (*GitCon return terraClient(*x.Terra, logger) } if x.Generic != nil { - if x.Generic.Provider == "terra" { + switch x.Generic.Provider { + case "terra": return terraClient(config.TerraRemote{Endpoint: x.Generic.Endpoint, Auth: x.Generic.Auth, Mode: "read-only"}, logger) + case "gen3": + return gen3Client(string(remote), config.Gen3Remote{ + Endpoint: x.Generic.Endpoint, Organization: x.Generic.GetOrganization(), + ProjectID: x.Generic.GetProjectId(), Bucket: x.Generic.GetBucketName(), + StoragePrefix: x.Generic.GetStoragePrefix(), + }, logger) + case "ga4gh", "auto", "cgc", "synapse": + return nil, fmt.Errorf("provider %q does not yet have an operational remote adapter", x.Generic.Provider) + default: + return nil, fmt.Errorf("unsupported remote provider %q", x.Generic.Provider) } - return &GitContext{RemoteType: config.GA4GHServerType, Endpoint: x.Generic.Endpoint, Organization: x.Generic.GetOrganization(), ProjectId: x.Generic.GetProjectId(), Logger: logger, Credential: &syconf.Credential{APIEndpoint: x.Generic.Endpoint}}, nil } return nil, fmt.Errorf("no valid remote configuration found for current remote: %s", remote) } @@ -69,10 +92,11 @@ func terraClient(remote config.TerraRemote, logger *slog.Logger) (*GitContext, e return nil, err } return &GitContext{ - RemoteType: config.TerraServerType, - Endpoint: remote.Endpoint, - Logger: logger, - Credential: &syconf.Credential{APIEndpoint: remote.Endpoint}, + RemoteType: config.TerraServerType, + Endpoint: remote.Endpoint, + Logger: logger, + Credential: &syconf.Credential{APIEndpoint: remote.Endpoint}, + Capabilities: Capabilities{Resolve: true, Download: true, ReadOnly: true}, }, nil } @@ -140,6 +164,7 @@ func localClient(remoteName string, remote config.LocalRemote, logger *slog.Logg StoragePrefix: storagePrefix, Logger: logger, Credential: cred, + Capabilities: Capabilities{Resolve: true, Download: true, Upload: true, Register: true}, }, nil } @@ -189,6 +214,7 @@ func newGitContext(profileConfig syconf.Credential, remote config.Gen3Remote, lo UploadConcurrency: uploadConcurrency, Logger: logger, Credential: &profileConfig, + Capabilities: Capabilities{Resolve: true, Download: true, Upload: true, Register: true}, }, nil } diff --git a/internal/remoteruntime/runtime_test.go b/internal/remoteruntime/runtime_test.go index db4c6644..3b13c195 100644 --- a/internal/remoteruntime/runtime_test.go +++ b/internal/remoteruntime/runtime_test.go @@ -86,6 +86,32 @@ func TestNewTerraRemoteContext(t *testing.T) { if gitCtx.Client != nil { t.Fatalf("Terra runtime should not create a Syfon client, got %+v", gitCtx.Client) } + if !gitCtx.CanResolve() || !gitCtx.CanDownload() || !gitCtx.IsReadOnly() || gitCtx.CanUpload() || gitCtx.CanRegister() { + t.Fatalf("unexpected Terra capabilities: %+v", gitCtx.Capabilities) + } +} + +func TestNewGenericTerraUsesProviderAdapter(t *testing.T) { + setupTestRepo(t) + cfg := &config.Config{Remotes: map[config.Remote]config.RemoteSelect{ + "anvil": {Generic: &config.GenericRemote{Endpoint: "https://data.terra.bio", Provider: "terra", Auth: "google-adc"}}, + }} + gitCtx, err := New(cfg, "anvil", drslog.GetLogger()) + if err != nil { + t.Fatal(err) + } + if gitCtx.RemoteType != config.TerraServerType || !gitCtx.IsReadOnly() || !gitCtx.CanDownload() { + t.Fatalf("generic Terra did not resolve through Terra adapter: %+v", gitCtx) + } +} + +func TestNewRejectsProviderWithoutOperationalAdapter(t *testing.T) { + cfg := &config.Config{Remotes: map[config.Remote]config.RemoteSelect{ + "cgc": {Generic: &config.GenericRemote{Endpoint: "https://example.test", Provider: "cgc", Auth: "bearer"}}, + }} + if _, err := New(cfg, "cgc", drslog.GetLogger()); err == nil { + t.Fatal("expected unimplemented provider adapter to fail explicitly") + } } func TestLocalClientResolvesBucketScopeMappings(t *testing.T) { From c8ebbdd5760c329ba9e31f9bd98076eb9ba1a228 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Mon, 27 Jul 2026 15:40:46 -0700 Subject: [PATCH 44/66] Clarify read-only Terra push error --- cmd/push/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/push/main.go b/cmd/push/main.go index afabd710..9b52f60e 100644 --- a/cmd/push/main.go +++ b/cmd/push/main.go @@ -65,7 +65,7 @@ var Cmd = &cobra.Command{ } if drsClient.IsReadOnly() || !drsClient.CanUpload() || !drsClient.CanRegister() { return fmt.Errorf( - "remote %q is read-only: git drs push cannot upload or register files\n"+ + "remote %q is read-only: git drs push cannot upload files to Terra or register DRS objects\n"+ "no files were uploaded, and you do not need to back out a commit that references existing Terra data\n"+ "to publish the commit and its DRS references, use ordinary git push to a Git remote; this pushes only Git metadata and does not upload files to Terra", remote, From 001ac7308f3f2c59eff882c2cbd71ed998fe9483 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 11:59:59 -0700 Subject: [PATCH 45/66] Support inline and non-first DRS access methods --- internal/resolver/resolver.go | 26 +++++++++---- internal/resolver/resolver_test.go | 59 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 7d356d88..6b665bf8 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -36,12 +36,23 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string if err != nil { return err } - if len(obj.AccessMethods) == 0 || obj.AccessMethods[0].AccessID == "" { - return fmt.Errorf("AnVIL DRS object has no supported access method") + var access *ResolvedAccess + for _, method := range obj.AccessMethods { + switch { + case method.AccessURL != nil && strings.TrimSpace(method.AccessURL.URL) != "": + access = method.AccessURL + case strings.TrimSpace(method.AccessID) != "": + access, err = r.GetAccess(ctx, drsURI, method.AccessID) + if err != nil { + return err + } + } + if access != nil { + break + } } - access, err := r.GetAccess(ctx, drsURI, obj.AccessMethods[0].AccessID) - if err != nil { - return err + if access == nil { + return fmt.Errorf("AnVIL DRS object has no supported access method") } if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { return err @@ -97,8 +108,9 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string } type AccessMethod struct { - Type string `json:"type"` - AccessID string `json:"access_id"` + Type string `json:"type"` + AccessID string `json:"access_id,omitempty"` + AccessURL *ResolvedAccess `json:"access_url,omitempty"` } type ResolvedObject struct { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index b8ac9a33..b80226ea 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -74,6 +74,65 @@ func TestDownloadToCacheUsesAccessURLHeaders(t *testing.T) { } } +func TestDownloadToCacheUsesInlineAccessURL(t *testing.T) { + const body = "data" + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Inline-Token"); got != "inline-secret" { + t.Errorf("unexpected inline access header: %q", got) + } + _, _ = w.Write([]byte(body)) + })) + defer download.Close() + + resolverServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ga4gh/drs/v1/objects/object-1" { + t.Errorf("inline access URL should not require an access endpoint request: %s", r.URL.Path) + http.NotFound(w, r) + return + } + _, _ = fmt.Fprintf(w, `{"id":"object-1","size":4,"access_methods":[{"type":"https","access_url":{"url":%q,"headers":["X-Inline-Token: inline-secret"]}}]}`, download.URL) + })) + defer resolverServer.Close() + + r, err := NewAnVILWithClient(resolverServer.URL, resolverServer.Client()) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "cache", "object-1") + if err := DownloadToCache(context.Background(), r, "drs://example.org/object-1", destination); err != nil { + t.Fatalf("DownloadToCache returned error: %v", err) + } +} + +func TestDownloadToCacheSelectsUsableAccessMethodAfterFirst(t *testing.T) { + const body = "data" + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer download.Close() + + resolverServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ga4gh/drs/v1/objects/object-1": + _, _ = w.Write([]byte(`{"id":"object-1","size":4,"access_methods":[{"type":"ftp"},{"type":"https","access_id":"a2"}]}`)) + case "/ga4gh/drs/v1/objects/object-1/access/a2": + _, _ = fmt.Fprintf(w, `{"url":%q}`, download.URL) + default: + http.NotFound(w, r) + } + })) + defer resolverServer.Close() + + r, err := NewAnVILWithClient(resolverServer.URL, resolverServer.Client()) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "cache", "object-1") + if err := DownloadToCache(context.Background(), r, "drs://example.org/object-1", destination); err != nil { + t.Fatalf("DownloadToCache returned error: %v", err) + } +} + func TestAnVILResolverAcceptsCompactDRSURI(t *testing.T) { const compactURI = "drs://drs.anv0:v2_e68887be-c583-375a-a773-48771192c8fa" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 6221437bb5eea37b7880ae1cb4cb726a378a39f9 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 12:12:25 -0700 Subject: [PATCH 46/66] Resolve unified Gen3 credential sources at runtime --- internal/remoteruntime/runtime.go | 84 ++++++++++++++++++++++-- internal/remoteruntime/runtime_test.go | 88 ++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/internal/remoteruntime/runtime.go b/internal/remoteruntime/runtime.go index d6fd3f55..0949e4fa 100644 --- a/internal/remoteruntime/runtime.go +++ b/internal/remoteruntime/runtime.go @@ -5,6 +5,9 @@ import ( "fmt" "log/slog" "net/url" + "os" + "os/exec" + "path/filepath" "strings" "time" @@ -70,7 +73,7 @@ func New(cfg *config.Config, remote config.Remote, logger *slog.Logger) (*GitCon case "terra": return terraClient(config.TerraRemote{Endpoint: x.Generic.Endpoint, Auth: x.Generic.Auth, Mode: "read-only"}, logger) case "gen3": - return gen3Client(string(remote), config.Gen3Remote{ + return gen3ClientWithCredential(string(remote), x.Generic.Credential, config.Gen3Remote{ Endpoint: x.Generic.Endpoint, Organization: x.Generic.GetOrganization(), ProjectID: x.Generic.GetProjectId(), Bucket: x.Generic.GetBucketName(), StoragePrefix: x.Generic.GetStoragePrefix(), @@ -101,8 +104,12 @@ func terraClient(remote config.TerraRemote, logger *slog.Logger) (*GitContext, e } func gen3Client(remoteName string, remote config.Gen3Remote, logger *slog.Logger) (*GitContext, error) { + return gen3ClientWithCredential(remoteName, "", remote, logger) +} + +func gen3ClientWithCredential(remoteName, source string, remote config.Gen3Remote, logger *slog.Logger) (*GitContext, error) { manager := calyprconf.NewConfigure(logger) - cred, err := manager.Load(remoteName) + cred, saveRefreshed, err := resolveGen3Credential(manager, source, remoteName, remote.Endpoint) if err != nil { return nil, err } @@ -111,12 +118,81 @@ func gen3Client(remoteName string, remote config.Gen3Remote, logger *slog.Logger if err := credentials.EnsureValidCredential(ctx, cred, logger); err != nil { return nil, WrapCredentialValidationError(remoteName, err) } - if err := manager.Save(cred); err != nil { - return nil, fmt.Errorf("save refreshed credential for remote %q: %w", remoteName, err) + if saveRefreshed { + if err := manager.Save(cred); err != nil { + return nil, fmt.Errorf("save refreshed credential for remote %q: %w", remoteName, err) + } } return newGitContext(*cred, remote, logger) } +type gen3CredentialManager interface { + Import(filePath, fenceToken string) (*calyprconf.Credential, error) + Load(profile string) (*calyprconf.Credential, error) +} + +// resolveGen3Credential turns the clone-local source selector into credential +// material. The configured remote endpoint remains authoritative: a credential +// source must not be able to redirect requests to another host. +func resolveGen3Credential(manager gen3CredentialManager, source, remoteName, endpoint string) (*calyprconf.Credential, bool, error) { + source = strings.TrimSpace(source) + if source == "" { + cred, err := manager.Load(remoteName) + return cred, true, err + } + + var cred *calyprconf.Credential + var err error + switch { + case strings.HasPrefix(source, "profile:"): + profile := strings.TrimSpace(strings.TrimPrefix(source, "profile:")) + cred, err = manager.Load(profile) + if err == nil { + cred.Profile = profile + } + if err != nil { + return nil, false, fmt.Errorf("load Gen3 credential profile %q: %w", profile, err) + } + cred.APIEndpoint = endpoint + return cred, true, nil + case strings.HasPrefix(source, "file:"): + fileName := strings.TrimSpace(strings.TrimPrefix(source, "file:")) + if strings.HasPrefix(fileName, "~/") { + if home, homeErr := os.UserHomeDir(); homeErr == nil { + fileName = filepath.Join(home, strings.TrimPrefix(fileName, "~/")) + } + } + cred, err = manager.Import(fileName, "") + if err != nil { + return nil, false, fmt.Errorf("import Gen3 credential file %q: %w", fileName, err) + } + case strings.HasPrefix(source, "env:"): + name := strings.TrimSpace(strings.TrimPrefix(source, "env:")) + token, ok := os.LookupEnv(name) + if !ok || strings.TrimSpace(token) == "" { + return nil, false, fmt.Errorf("Gen3 credential environment variable %q is empty or unset", name) + } + cred = &calyprconf.Credential{AccessToken: strings.TrimSpace(token)} + case strings.HasPrefix(source, "helper:"): + name := strings.TrimSpace(strings.TrimPrefix(source, "helper:")) + output, helperErr := exec.Command(name).Output() + if helperErr != nil { + return nil, false, fmt.Errorf("run Gen3 credential helper %q: %w", name, helperErr) + } + token := strings.TrimSpace(string(output)) + if token == "" { + return nil, false, fmt.Errorf("Gen3 credential helper %q returned an empty token", name) + } + cred = &calyprconf.Credential{AccessToken: token} + default: + return nil, false, fmt.Errorf("unsupported Gen3 credential source %q", source) + } + + cred.Profile = remoteName + cred.APIEndpoint = endpoint + return cred, false, nil +} + func localClient(remoteName string, remote config.LocalRemote, logger *slog.Logger) (*GitContext, error) { if username, password, err := gitrepo.GetRemoteBasicAuth(remoteName); err == nil && username != "" && password != "" { remote.BasicUsername = username diff --git a/internal/remoteruntime/runtime_test.go b/internal/remoteruntime/runtime_test.go index 3b13c195..8d3cefd9 100644 --- a/internal/remoteruntime/runtime_test.go +++ b/internal/remoteruntime/runtime_test.go @@ -1,8 +1,10 @@ package remoteruntime import ( + "errors" "os" "os/exec" + "path/filepath" "testing" "github.com/calypr/git-drs/internal/config" @@ -11,6 +13,31 @@ import ( syconf "github.com/calypr/syfon/client/config" ) +type fakeGen3CredentialManager struct { + importPath string + loadName string + imported *syconf.Credential + loaded *syconf.Credential +} + +func (m *fakeGen3CredentialManager) Import(filePath, _ string) (*syconf.Credential, error) { + m.importPath = filePath + if m.imported == nil { + return nil, errors.New("unexpected import") + } + copy := *m.imported + return ©, nil +} + +func (m *fakeGen3CredentialManager) Load(profile string) (*syconf.Credential, error) { + m.loadName = profile + if m.loaded == nil { + return nil, errors.New("unexpected load") + } + copy := *m.loaded + return ©, nil +} + func setupTestRepo(t *testing.T) string { t.Helper() @@ -105,6 +132,67 @@ func TestNewGenericTerraUsesProviderAdapter(t *testing.T) { } } +func TestResolveGen3CredentialUsesSelectedProfile(t *testing.T) { + manager := &fakeGen3CredentialManager{loaded: &syconf.Credential{ + Profile: "research", APIEndpoint: "https://old.example", APIKey: "key", + }} + cred, save, err := resolveGen3Credential(manager, "profile:research", "production", "https://gen3.example") + if err != nil { + t.Fatal(err) + } + if manager.loadName != "research" || cred.Profile != "research" { + t.Fatalf("selected profile was not loaded: name=%q credential=%+v", manager.loadName, cred) + } + if cred.APIEndpoint != "https://gen3.example" { + t.Fatalf("APIEndpoint = %q, want configured remote endpoint", cred.APIEndpoint) + } + if !save { + t.Fatal("a refreshed profile credential should be saved to its source profile") + } +} + +func TestResolveGen3CredentialImportsFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + manager := &fakeGen3CredentialManager{imported: &syconf.Credential{APIKey: "key"}} + cred, save, err := resolveGen3Credential(manager, "file:~/.gen3/credentials.json", "production", "https://gen3.example") + if err != nil { + t.Fatal(err) + } + wantPath := filepath.Join(os.Getenv("HOME"), ".gen3", "credentials.json") + if manager.importPath != wantPath { + t.Fatalf("import path = %q, want %q", manager.importPath, wantPath) + } + if cred.Profile != "production" || cred.APIEndpoint != "https://gen3.example" || save { + t.Fatalf("unexpected imported credential: credential=%+v save=%v", cred, save) + } +} + +func TestResolveGen3CredentialReadsEnvironment(t *testing.T) { + t.Setenv("GEN3_TOKEN", " token-from-env \n") + cred, save, err := resolveGen3Credential(&fakeGen3CredentialManager{}, "env:GEN3_TOKEN", "production", "https://gen3.example") + if err != nil { + t.Fatal(err) + } + if cred.AccessToken != "token-from-env" || cred.APIEndpoint != "https://gen3.example" || save { + t.Fatalf("unexpected environment credential: credential=%+v save=%v", cred, save) + } +} + +func TestResolveGen3CredentialRunsHelper(t *testing.T) { + dir := t.TempDir() + helper := filepath.Join(dir, "gen3-token-helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf 'token-from-helper\\n'\n"), 0o700); err != nil { + t.Fatal(err) + } + cred, save, err := resolveGen3Credential(&fakeGen3CredentialManager{}, "helper:"+helper, "production", "https://gen3.example") + if err != nil { + t.Fatal(err) + } + if cred.AccessToken != "token-from-helper" || cred.APIEndpoint != "https://gen3.example" || save { + t.Fatalf("unexpected helper credential: credential=%+v save=%v", cred, save) + } +} + func TestNewRejectsProviderWithoutOperationalAdapter(t *testing.T) { cfg := &config.Config{Remotes: map[config.Remote]config.RemoteSelect{ "cgc": {Generic: &config.GenericRemote{Endpoint: "https://example.test", Provider: "cgc", Auth: "bearer"}}, From 6b899fa5b021ddf89e6aab49c5eb748630fd627d Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 12:27:57 -0700 Subject: [PATCH 47/66] Resolve scoped Gen3 buckets at runtime --- internal/remoteruntime/bucket_lookup.go | 90 +++++++++++++++++++++++++ internal/remoteruntime/runtime.go | 14 +++- internal/remoteruntime/runtime_test.go | 32 +++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 internal/remoteruntime/bucket_lookup.go diff --git a/internal/remoteruntime/bucket_lookup.go b/internal/remoteruntime/bucket_lookup.go new file mode 100644 index 00000000..2994519c --- /dev/null +++ b/internal/remoteruntime/bucket_lookup.go @@ -0,0 +1,90 @@ +package remoteruntime + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/calypr/git-drs/internal/gitrepo" + bucketapi "github.com/calypr/syfon/apigen/client/bucketapi" + syfoncommon "github.com/calypr/syfon/common" +) + +// resolveBucketScopeFromServer discovers the bucket exposed for a Gen3 scope. +// This is the runtime fallback for remotes configured with a scope but without +// an explicit storage location or repository-local bucket mapping. +func resolveBucketScopeFromServer(ctx context.Context, endpoint, token, organization, project, preferredBucket string) (gitrepo.ResolvedBucketScope, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(endpoint, "/")+"/data/buckets", nil) + if err != nil { + return gitrepo.ResolvedBucketScope{}, fmt.Errorf("build bucket list request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return gitrepo.ResolvedBucketScope{}, fmt.Errorf("request bucket list: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return gitrepo.ResolvedBucketScope{}, fmt.Errorf("bucket list failed with status %d", resp.StatusCode) + } + + var payload bucketapi.BucketsResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return gitrepo.ResolvedBucketScope{}, fmt.Errorf("decode bucket list response: %w", err) + } + + bucket, err := resolveBucketFromPayload(payload, organization, project, preferredBucket) + if err != nil { + return gitrepo.ResolvedBucketScope{}, err + } + return gitrepo.ResolvedBucketScope{Bucket: bucket}, nil +} + +func resolveBucketFromPayload(payload bucketapi.BucketsResponse, organization, project, preferredBucket string) (string, error) { + projectResource, err := syfoncommon.ResourcePath(organization, project) + if err != nil { + return "", err + } + orgResource, err := syfoncommon.ResourcePath(organization, "") + if err != nil { + return "", err + } + + for _, resource := range []string{projectResource, orgResource} { + matches := make([]string, 0) + for bucket, meta := range payload.S3BUCKETS { + if meta.Programs == nil { + continue + } + for _, candidate := range *meta.Programs { + if syfoncommon.NormalizeAccessResource(candidate) == syfoncommon.NormalizeAccessResource(resource) { + matches = append(matches, bucket) + break + } + } + } + slices.Sort(matches) + matches = slices.Compact(matches) + if len(matches) == 0 { + continue + } + if preferredBucket != "" { + for _, bucket := range matches { + if strings.EqualFold(bucket, strings.TrimSpace(preferredBucket)) { + return bucket, nil + } + } + return "", fmt.Errorf("selected bucket %q does not match resource %q; choose one of: %s", preferredBucket, resource, strings.Join(matches, ", ")) + } + if len(matches) == 1 { + return matches[0], nil + } + return "", fmt.Errorf("multiple visible server buckets matched resource %q: %s", resource, strings.Join(matches, ", ")) + } + + return "", fmt.Errorf("no visible server bucket matched organization=%q project=%q", organization, project) +} diff --git a/internal/remoteruntime/runtime.go b/internal/remoteruntime/runtime.go index 0949e4fa..4ac6b44e 100644 --- a/internal/remoteruntime/runtime.go +++ b/internal/remoteruntime/runtime.go @@ -260,7 +260,19 @@ func newGitContext(profileConfig syconf.Credential, remote config.Gen3Remote, lo remote.GetStoragePrefix(), ) if err != nil { - return nil, err + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + scope, err = resolveBucketScopeFromServer( + ctx, + profileConfig.APIEndpoint, + profileConfig.AccessToken, + remote.GetOrganization(), + projectID, + remote.GetBucketName(), + ) + if err != nil { + return nil, fmt.Errorf("failed resolving bucket mapping for organization=%q project=%q: %w", remote.GetOrganization(), projectID, err) + } } raw, err := syclient.New(profileConfig.APIEndpoint, syclient.WithBearerToken(profileConfig.AccessToken)) diff --git a/internal/remoteruntime/runtime_test.go b/internal/remoteruntime/runtime_test.go index 8d3cefd9..1dfa18eb 100644 --- a/internal/remoteruntime/runtime_test.go +++ b/internal/remoteruntime/runtime_test.go @@ -2,6 +2,8 @@ package remoteruntime import ( "errors" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -257,3 +259,33 @@ func TestNewGitContextReadsLFSConcurrentTransfers(t *testing.T) { t.Fatalf("UploadConcurrency = %d, want 7", gitCtx.UploadConcurrency) } } + +func TestNewGitContextDiscoversBucketForScopeOnlyRemote(t *testing.T) { + setupTestRepo(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/data/buckets" { + t.Fatalf("request path = %q, want /data/buckets", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer token" { + t.Fatalf("Authorization = %q, want bearer token", got) + } + _, _ = w.Write([]byte(`{"S3_BUCKETS":{"scope-bucket":{"programs":["/organization/org1/project/proj1"]}}}`)) + })) + defer server.Close() + + gitCtx, err := newGitContext(syconf.Credential{ + APIEndpoint: server.URL, + AccessToken: "token", + }, config.Gen3Remote{ + Endpoint: server.URL, + Organization: "org1", + ProjectID: "proj1", + }, drslog.GetLogger()) + if err != nil { + t.Fatalf("newGitContext failed: %v", err) + } + if gitCtx.BucketName != "scope-bucket" { + t.Fatalf("BucketName = %q, want scope-bucket", gitCtx.BucketName) + } +} From 76098af6190ad3254afdcd00af01dd6f87c905b6 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 12:40:52 -0700 Subject: [PATCH 48/66] Preserve source routing in add-ref pointers --- cmd/addref/add-ref.go | 26 ++++++++------------------ cmd/addref/add-ref_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index c7cecb80..5273f4dd 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -100,15 +100,7 @@ var Cmd = &cobra.Command{ } oid := addRefLocalOID(drsUri, remoteName, &obj) - if client.IsReadOnly() { - if err := lfs.CreateDRSPointer(&obj, dstPath, drsUri); err != nil { - return err - } - } else if hasContentSHA256(&obj) { - if err := lfs.CreateLfsPointerWithOID(&obj, dstPath, oid); err != nil { - return err - } - } else if err := lfs.CreateDRSPointer(&obj, dstPath, drsUri); err != nil { + if err := createAddRefPointer(&obj, dstPath, drsUri); err != nil { return err } if _, err := gitrepo.TrackReadOnly(cmd.Context(), args[1]); err != nil { @@ -283,13 +275,7 @@ func runManifest(cmd *cobra.Command, filename string) error { if err := os.MkdirAll(filepath.Dir(e.destination), 0o755); err != nil { return err } - if runtime.RemoteType == config.TerraServerType { - err = lfs.CreateDRSPointer(&e.object, e.destination, e.uri) - } else if hasContentSHA256(&e.object) { - err = lfs.CreateLfsPointerWithOID(&e.object, e.destination, addRefLocalOID(e.uri, remoteName, &e.object)) - } else { - err = lfs.CreateDRSPointer(&e.object, e.destination, e.uri) - } + err = createAddRefPointer(&e.object, e.destination, e.uri) if err != nil { return err } @@ -301,8 +287,12 @@ func runManifest(cmd *cobra.Command, filename string) error { return nil } -func hasContentSHA256(obj *drsapi.DrsObject) bool { - return obj != nil && drsobject.NormalizeChecksum(hash.ConvertDrsChecksumsToHashInfo(obj.Checksums).SHA256) != "" +// createAddRefPointer keeps the source authority in Git. The metadata written +// under .git/drs is only a local cache, so a checksum-only pointer would leave +// another clone unable to route hydration back to the authority that resolved +// the reference. +func createAddRefPointer(obj *drsapi.DrsObject, dst, sourceURI string) error { + return lfs.CreateDRSPointer(obj, dst, sourceURI) } func addRefLocalOID(sourceURI string, remoteName config.Remote, obj *drsapi.DrsObject) string { diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index 52c4e270..8996ea23 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -124,6 +124,28 @@ func TestCreateDRSPointerPreservesSourceURI(t *testing.T) { } } +func TestAddRefPointerPreservesSourceURIWhenObjectHasSHA256(t *testing.T) { + obj := &drsapi.DrsObject{ + Size: 42, + Checksums: []drsapi.Checksum{{Type: "sha256", Checksum: strings.Repeat("a", 64)}}, + } + path := filepath.Join(t.TempDir(), "pointer") + if err := createAddRefPointer(obj, path, "drs://source.example/object-1"); err != nil { + t.Fatalf("createAddRefPointer error: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read pointer: %v", err) + } + want := "version https://calypr.github.io/spec/v1\n" + + "oid drs://source.example/object-1\n" + + "size 42\n" + + "sha256 " + strings.Repeat("a", 64) + "\n" + if string(data) != want { + t.Fatalf("pointer mismatch:\n got: %q\nwant: %q", data, want) + } +} + func TestAddRefTrackingMakesDRSPointerDiscoverable(t *testing.T) { repo := t.TempDir() runGitCmd(t, repo, "init") From 48c83ab762a1e9b6a40e6950ce18f6622c150141 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 12:57:44 -0700 Subject: [PATCH 49/66] Validate AnVIL checksums before caching downloads --- internal/resolver/resolver.go | 25 ++++++++++- internal/resolver/resolver_test.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 6b665bf8..2cea6340 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3,6 +3,9 @@ package resolver import ( "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -87,7 +90,8 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string tmp.Close() return fmt.Errorf("AnVIL data download returned HTTP %d", resp.StatusCode) } - _, copyErr := io.Copy(tmp, resp.Body) + hasher := sha256.New() + _, copyErr := io.Copy(io.MultiWriter(tmp, hasher), resp.Body) closeErr := tmp.Close() if copyErr != nil { return fmt.Errorf("AnVIL data download interrupted: %w", copyErr) @@ -104,6 +108,25 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string return fmt.Errorf("AnVIL data size mismatch: expected %d, got %d", obj.Size, info.Size()) } } + for _, checksum := range obj.Checksums { + checksumType := strings.ToLower(strings.TrimSpace(checksum.Type)) + if checksumType != "sha256" && checksumType != "sha-256" { + continue + } + expected := strings.TrimSpace(checksum.Checksum) + if prefix, value, ok := strings.Cut(expected, ":"); ok && + (strings.EqualFold(strings.TrimSpace(prefix), "sha256") || strings.EqualFold(strings.TrimSpace(prefix), "sha-256")) { + expected = strings.TrimSpace(value) + } + expectedBytes, decodeErr := hex.DecodeString(expected) + if decodeErr != nil || len(expectedBytes) != sha256.Size { + return fmt.Errorf("AnVIL DRS object has an invalid %s checksum", checksumType) + } + actual := hasher.Sum(nil) + if subtle.ConstantTimeCompare(actual, expectedBytes) != 1 { + return fmt.Errorf("AnVIL data checksum mismatch for %s", checksumType) + } + } return os.Rename(tmpName, destination) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index b80226ea..42585b13 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -2,10 +2,13 @@ package resolver import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -133,6 +136,69 @@ func TestDownloadToCacheSelectsUsableAccessMethodAfterFirst(t *testing.T) { } } +func TestDownloadToCacheValidatesSHA256BeforePromotion(t *testing.T) { + const body = "data" + correctSum := sha256.Sum256([]byte(body)) + incorrectSum := sha256.Sum256([]byte("oops")) // Same length as body. + + for _, test := range []struct { + name string + checksum string + wantErr bool + wantContent string + }{ + {name: "matching", checksum: hex.EncodeToString(correctSum[:]), wantContent: body}, + {name: "mismatching same-size payload", checksum: hex.EncodeToString(incorrectSum[:]), wantErr: true, wantContent: "previous"}, + } { + t.Run(test.name, func(t *testing.T) { + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer download.Close() + + resolverServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ga4gh/drs/v1/objects/object-1" { + http.NotFound(w, r) + return + } + _, _ = fmt.Fprintf(w, `{"id":"object-1","size":4,"checksums":[{"type":"sha256","checksum":%q}],"access_methods":[{"type":"https","access_url":{"url":%q}}]}`, test.checksum, download.URL) + })) + defer resolverServer.Close() + + r, err := NewAnVILWithClient(resolverServer.URL, resolverServer.Client()) + if err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(t.TempDir(), "cache") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + destination := filepath.Join(cacheDir, "object-1") + if err := os.WriteFile(destination, []byte("previous"), 0o644); err != nil { + t.Fatal(err) + } + + err = DownloadToCache(context.Background(), r, "drs://example.org/object-1", destination) + if test.wantErr && (err == nil || !strings.Contains(err.Error(), "checksum mismatch")) { + t.Fatalf("expected checksum mismatch, got %v", err) + } + if !test.wantErr && err != nil { + t.Fatalf("DownloadToCache returned error: %v", err) + } + content, readErr := os.ReadFile(destination) + if readErr != nil { + t.Fatal(readErr) + } + if string(content) != test.wantContent { + t.Fatalf("cached content = %q, want %q", content, test.wantContent) + } + if temporary, globErr := filepath.Glob(filepath.Join(cacheDir, ".git-drs-download-*")); globErr != nil || len(temporary) != 0 { + t.Fatalf("temporary downloads remain after validation: %v (glob error: %v)", temporary, globErr) + } + }) + } +} + func TestAnVILResolverAcceptsCompactDRSURI(t *testing.T) { const compactURI = "drs://drs.anv0:v2_e68887be-c583-375a-a773-48771192c8fa" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From be4a0c67ee051ff311dfeed073549aee5e08ddcf Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 13:12:11 -0700 Subject: [PATCH 50/66] Hydrate DRS OIDs through the URI downloader --- internal/transfer/download.go | 5 +++++ internal/transfer/download_test.go | 32 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/transfer/download.go b/internal/transfer/download.go index 33407a16..9bc95b2e 100644 --- a/internal/transfer/download.go +++ b/internal/transfer/download.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/calypr/git-drs/internal/drsobject" + "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/lookup" "github.com/calypr/git-drs/internal/remoteruntime" drsapi "github.com/calypr/syfon/apigen/client/drs" @@ -76,6 +77,10 @@ func DownloadDRSURIToCachePath(ctx context.Context, drsCtx *remoteruntime.GitCon } func DownloadToCachePath(ctx context.Context, drsCtx *remoteruntime.GitContext, oid, cachePath string) error { + if lfs.IsDRSURI(oid) { + return DownloadDRSURIToCachePath(ctx, drsCtx, oid, cachePath) + } + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { return fmt.Errorf("mkdir for cache path: %w", err) } diff --git a/internal/transfer/download_test.go b/internal/transfer/download_test.go index f88a5f8e..52d58755 100644 --- a/internal/transfer/download_test.go +++ b/internal/transfer/download_test.go @@ -20,6 +20,38 @@ type downloadRoundTripFunc func(*http.Request) (*http.Response, error) func (f downloadRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestDownloadToCachePathDispatchesDRSOIDToURIResolver(t *testing.T) { + t.Parallel() + + for _, oid := range []string{"drs://example.test/object-1", "//example.test/object-1"} { + oid := oid + t.Run(oid, func(t *testing.T) { + t.Parallel() + + var requestPath string + httpClient := &http.Client{Transport: downloadRoundTripFunc(func(r *http.Request) (*http.Response, error) { + requestPath = r.URL.Path + return nil, io.EOF + })} + raw, err := syclient.New("http://example.test", syclient.WithHTTPClient(httpClient)) + if err != nil { + t.Fatalf("syclient.New: %v", err) + } + + err = DownloadToCachePath(context.Background(), &remoteruntime.GitContext{Client: raw.(*syclient.Client)}, oid, filepath.Join(t.TempDir(), "object")) + if err == nil { + t.Fatal("DownloadToCachePath unexpectedly succeeded") + } + if strings.Contains(requestPath, "/checksum/") { + t.Fatalf("DRS OID was sent to checksum lookup: %s", requestPath) + } + if !strings.Contains(requestPath, "/objects/drs://example.test/object-1") { + t.Fatalf("DRS OID was not sent to URI lookup: %s", requestPath) + } + }) + } +} + func TestBulkAccessURLsForObjects(t *testing.T) { accessID := "s3" methods := []drsapi.AccessMethod{{Type: drsapi.AccessMethodTypeS3, AccessId: &accessID}} From a9c249e8b0409eec7748736b239921bb185472ad Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 19:53:09 -0700 Subject: [PATCH 51/66] Skip DRS URI references during push sync --- internal/transfer/push.go | 8 ++++++++ internal/transfer/push_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/transfer/push.go b/internal/transfer/push.go index 30c96b35..94c5b0fa 100644 --- a/internal/transfer/push.go +++ b/internal/transfer/push.go @@ -108,6 +108,14 @@ func (s *batchSyncSession) normalizeFiles(files map[string]lfs.LfsFileInfo) { if oid == "" { continue } + // DRS URI pointers identify an existing remote object; they are not + // SHA-256 checksums. Leave them to the URI-aware fetch path rather than + // sending the authority/object value through checksum lookup, + // registration, or upload synchronization. + if lfs.IsDRSURI(oid) { + s.debug("skipping DRS URI pointer during checksum push synchronization", "oid", oid) + continue + } if _, exists := s.filesByOID[oid]; exists { continue } diff --git a/internal/transfer/push_test.go b/internal/transfer/push_test.go index f64e6899..8a65afd7 100644 --- a/internal/transfer/push_test.go +++ b/internal/transfer/push_test.go @@ -39,6 +39,33 @@ func TestBatchSyncSessionNormalizeFilesDeduplicatesByOID(t *testing.T) { } } +func TestBatchSyncSessionNormalizeFilesExcludesDRSURIReferences(t *testing.T) { + session := &batchSyncSession{} + checksum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + session.normalizeFiles(map[string]lfs.LfsFileInfo{ + "data/checksum.dat": { + Name: "data/checksum.dat", + Oid: checksum, + }, + "data/parsed-reference.dat": { + Name: "data/parsed-reference.dat", + Oid: "//authority.example/object-with-sha256", + }, + "data/canonical-reference.dat": { + Name: "data/canonical-reference.dat", + Oid: "drs://authority.example/another-object", + }, + }) + + if len(session.oids) != 1 || session.oids[0] != checksum { + t.Fatalf("expected only checksum OID in push synchronization, got %+v", session.oids) + } + if len(session.filesByOID) != 1 { + t.Fatalf("expected only checksum file in push synchronization, got %+v", session.filesByOID) + } +} + func TestAddURLObjectRegistersWithoutLocalPayloadUpload(t *testing.T) { t.Chdir(t.TempDir()) oid := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" From 3018371b7e7a097c4cd004c604f72bdd067379b4 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Tue, 28 Jul 2026 20:30:38 -0700 Subject: [PATCH 52/66] Reject unresolved providers during remote add --- cmd/remote/add/unified.go | 3 +++ cmd/remote/add/unified_test.go | 22 ++++++++++++++++++++++ docs/commands.md | 2 ++ 3 files changed, 27 insertions(+) diff --git a/cmd/remote/add/unified.go b/cmd/remote/add/unified.go index b887c24e..f9977a92 100644 --- a/cmd/remote/add/unified.go +++ b/cmd/remote/add/unified.go @@ -49,6 +49,9 @@ func runUnified(cmd *cobra.Command, args []string) error { if !validChoice(provider, "auto", "ga4gh", "gen3", "terra", "cgc", "synapse") { return fmt.Errorf("unsupported provider %q", provider) } + if provider == "auto" { + return fmt.Errorf("cannot determine provider for endpoint %q; specify --provider", endpoint) + } if !validAuth(auth) { return fmt.Errorf("unsupported authentication method %q", auth) } diff --git a/cmd/remote/add/unified_test.go b/cmd/remote/add/unified_test.go index 3710e0ce..21ba9256 100644 --- a/cmd/remote/add/unified_test.go +++ b/cmd/remote/add/unified_test.go @@ -2,6 +2,7 @@ package add import ( "bytes" + "os" "testing" "github.com/calypr/git-drs/internal/config" @@ -36,6 +37,7 @@ func TestUnifiedAddDerivesURLNameAndRejectsHTTP(t *testing.T) { if err := runUnified(Cmd, []string{"http://drs.example.org"}); err == nil { t.Fatal("expected HTTP rejection") } + providerFlag = "ga4gh" if err := runUnified(Cmd, []string{"https://drs.example.org"}); err != nil { t.Fatal(err) } @@ -48,6 +50,26 @@ func TestUnifiedAddDerivesURLNameAndRejectsHTTP(t *testing.T) { } } +func TestUnifiedAddRejectsURLWithoutProviderBeforeInitializing(t *testing.T) { + repo := testutils.SetupTestGitRepo(t) + resetUnifiedFlags(t) + + err := runUnified(Cmd, []string{"https://drs.example.org"}) + if err == nil { + t.Fatal("expected provider auto rejection") + } + if _, statErr := os.Stat(repo + "/.git-drs"); !os.IsNotExist(statErr) { + t.Fatalf("remote validation modified repository state: .git-drs stat error = %v", statErr) + } + cfg, loadErr := config.LoadConfig() + if loadErr != nil { + t.Fatal(loadErr) + } + if len(cfg.Remotes) != 0 { + t.Fatalf("remote validation persisted configuration: %+v", cfg.Remotes) + } +} + func resetUnifiedFlags(t *testing.T) { old := []string{scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag} scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag = "", "auto", "", "auto", "", "" diff --git a/docs/commands.md b/docs/commands.md index b4b5ccd8..e59a9530 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -63,6 +63,8 @@ are `--scope`, `--auth`, `--provider`, `--storage`, and `--checkout`. Checkout mode is `pointers` or `hydrate`. Provider values are `auto`, `ga4gh`, `gen3`, `terra`, `cgc`, and `synapse`. +Built-in aliases resolve `auto` to their catalog provider; a raw HTTPS endpoint +must specify a concrete `--provider` before any repository state is modified. Authentication values are `auto`, `none`, `bearer`, `basic`, `google-adc`, `provider-helper`, or `provider-helper:`. Presets are expanded once and the resolved endpoint, provider, authentication method, and From 3f294a08df96ef6b021311f6eb6f2a665515f6f8 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 09:16:00 -0700 Subject: [PATCH 53/66] Validate generic Gen3 authentication modes --- cmd/remote/add/unified.go | 12 ++++++++++++ cmd/remote/add/unified_test.go | 19 +++++++++++++++++++ internal/remoteruntime/runtime.go | 15 ++++++++++----- internal/remoteruntime/runtime_test.go | 16 ++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/cmd/remote/add/unified.go b/cmd/remote/add/unified.go index f9977a92..0831b34c 100644 --- a/cmd/remote/add/unified.go +++ b/cmd/remote/add/unified.go @@ -55,6 +55,9 @@ func runUnified(cmd *cobra.Command, args []string) error { if !validAuth(auth) { return fmt.Errorf("unsupported authentication method %q", auth) } + if err := validateProviderAuth(provider, auth); err != nil { + return err + } u, err := url.ParseRequestURI(strings.TrimSpace(endpoint)) if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { return fmt.Errorf("endpoint must be an HTTPS URL without credentials, or a built-in alias") @@ -131,6 +134,15 @@ func validChoice(v string, choices ...string) bool { func validAuth(v string) bool { return validChoice(v, "auto", "none", "bearer", "basic", "google-adc", "provider-helper") || strings.HasPrefix(v, "provider-helper:") } +func validateProviderAuth(provider, auth string) error { + if provider != "gen3" { + return nil + } + if validChoice(auth, "bearer", "provider-helper", "provider-helper:gen3-profile") { + return nil + } + return fmt.Errorf("authentication method %q is not supported by the Gen3 remote adapter; use bearer or provider-helper:gen3-profile", auth) +} func validCredentialSource(v string) bool { if v == "stdin" { return true diff --git a/cmd/remote/add/unified_test.go b/cmd/remote/add/unified_test.go index 21ba9256..42086c66 100644 --- a/cmd/remote/add/unified_test.go +++ b/cmd/remote/add/unified_test.go @@ -70,6 +70,25 @@ func TestUnifiedAddRejectsURLWithoutProviderBeforeInitializing(t *testing.T) { } } +func TestUnifiedAddRejectsUnsupportedGen3AuthenticationBeforeInitializing(t *testing.T) { + for _, auth := range []string{"auto", "none", "basic", "google-adc", "provider-helper:other"} { + t.Run(auth, func(t *testing.T) { + repo := testutils.SetupTestGitRepo(t) + resetUnifiedFlags(t) + providerFlag = "gen3" + authFlag = auth + + err := runUnified(Cmd, []string{"https://gen3.example"}) + if err == nil { + t.Fatalf("expected Gen3 authentication method %q to be rejected", auth) + } + if _, statErr := os.Stat(repo + "/.git-drs"); !os.IsNotExist(statErr) { + t.Fatalf("remote validation modified repository state: .git-drs stat error = %v", statErr) + } + }) + } +} + func resetUnifiedFlags(t *testing.T) { old := []string{scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag} scopeFlag, authFlag, credentialFlag, providerFlag, storageFlag, checkoutFlag = "", "auto", "", "auto", "", "" diff --git a/internal/remoteruntime/runtime.go b/internal/remoteruntime/runtime.go index 4ac6b44e..b0832fc1 100644 --- a/internal/remoteruntime/runtime.go +++ b/internal/remoteruntime/runtime.go @@ -73,11 +73,16 @@ func New(cfg *config.Config, remote config.Remote, logger *slog.Logger) (*GitCon case "terra": return terraClient(config.TerraRemote{Endpoint: x.Generic.Endpoint, Auth: x.Generic.Auth, Mode: "read-only"}, logger) case "gen3": - return gen3ClientWithCredential(string(remote), x.Generic.Credential, config.Gen3Remote{ - Endpoint: x.Generic.Endpoint, Organization: x.Generic.GetOrganization(), - ProjectID: x.Generic.GetProjectId(), Bucket: x.Generic.GetBucketName(), - StoragePrefix: x.Generic.GetStoragePrefix(), - }, logger) + switch x.Generic.Auth { + case "bearer", "provider-helper", "provider-helper:gen3-profile": + return gen3ClientWithCredential(string(remote), x.Generic.Credential, config.Gen3Remote{ + Endpoint: x.Generic.Endpoint, Organization: x.Generic.GetOrganization(), + ProjectID: x.Generic.GetProjectId(), Bucket: x.Generic.GetBucketName(), + StoragePrefix: x.Generic.GetStoragePrefix(), + }, logger) + default: + return nil, fmt.Errorf("authentication method %q is not supported by the Gen3 remote adapter", x.Generic.Auth) + } case "ga4gh", "auto", "cgc", "synapse": return nil, fmt.Errorf("provider %q does not yet have an operational remote adapter", x.Generic.Provider) default: diff --git a/internal/remoteruntime/runtime_test.go b/internal/remoteruntime/runtime_test.go index 1dfa18eb..ee19fa25 100644 --- a/internal/remoteruntime/runtime_test.go +++ b/internal/remoteruntime/runtime_test.go @@ -204,6 +204,22 @@ func TestNewRejectsProviderWithoutOperationalAdapter(t *testing.T) { } } +func TestNewGenericGen3RejectsUnsupportedAuthentication(t *testing.T) { + for _, auth := range []string{"auto", "none", "basic", "google-adc", "provider-helper:other"} { + t.Run(auth, func(t *testing.T) { + cfg := &config.Config{Remotes: map[config.Remote]config.RemoteSelect{ + "gen3": {Generic: &config.GenericRemote{ + Endpoint: "https://gen3.example", Provider: "gen3", Auth: auth, + Scope: "program/project", Credential: "profile:stored", + }}, + }} + if _, err := New(cfg, "gen3", drslog.GetLogger()); err == nil { + t.Fatalf("expected Gen3 authentication method %q to fail explicitly", auth) + } + }) + } +} + func TestLocalClientResolvesBucketScopeMappings(t *testing.T) { setupTestRepo(t) From 191c38e03017519c02c84097c244d9250387291e Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 09:23:55 -0700 Subject: [PATCH 54/66] Track add-ref destinations from repository root --- internal/gitrepo/gitattributes_tracking.go | 26 +++++++++----- .../gitrepo/gitattributes_tracking_test.go | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/internal/gitrepo/gitattributes_tracking.go b/internal/gitrepo/gitattributes_tracking.go index 9180a42c..f14c7aed 100644 --- a/internal/gitrepo/gitattributes_tracking.go +++ b/internal/gitrepo/gitattributes_tracking.go @@ -12,10 +12,14 @@ import ( ) func TrackPatterns(_ context.Context, patterns []string, verbose bool, dryRun bool) (string, error) { + return trackPatternsAtPath(patterns, verbose, dryRun, ".gitattributes") +} + +func trackPatternsAtPath(patterns []string, verbose bool, dryRun bool, attributesPath string) (string, error) { changedAttribLines := make(map[string]string, len(patterns)) var output strings.Builder - attribContents, err := readLocalGitAttributes() + attribContents, err := readGitAttributes(attributesPath) if err != nil { return "", fmt.Errorf("git drs track failed: %w", err) } @@ -43,7 +47,7 @@ func TrackPatterns(_ context.Context, patterns []string, verbose bool, dryRun bo } if !dryRun { - if err := writeMergedGitAttributes(attribContents, changedAttribLines, false); err != nil { + if err := writeMergedGitAttributes(attributesPath, attribContents, changedAttribLines, false); err != nil { return "", fmt.Errorf("git drs track failed: %w", err) } } @@ -143,16 +147,16 @@ func UntrackPatterns(_ context.Context, patterns []string, _ bool, dryRun bool) } func TrackReadOnly(ctx context.Context, path string) (bool, error) { - if _, err := TrackPatterns(ctx, []string{path}, false, false); err != nil { - return false, fmt.Errorf("git lfs track failed: %w", err) - } - repoRoot, err := GitTopLevel() if err != nil { return false, err } attrPath := filepath.Join(repoRoot, ".gitattributes") + if _, err := trackPatternsAtPath([]string{path}, false, false, attrPath); err != nil { + return false, fmt.Errorf("git lfs track failed: %w", err) + } + changed, err := UpsertDRSRouteLines(attrPath, "ro", []string{path}) if err != nil { return false, err @@ -162,7 +166,11 @@ func TrackReadOnly(ctx context.Context, path string) (bool, error) { } func readLocalGitAttributes() ([]byte, error) { - data, err := os.ReadFile(".gitattributes") + return readGitAttributes(".gitattributes") +} + +func readGitAttributes(attributesPath string) ([]byte, error) { + data, err := os.ReadFile(attributesPath) if err != nil { if os.IsNotExist(err) { return nil, nil @@ -189,7 +197,7 @@ func parseKnownLFSPatterns(content []byte) map[string]string { return known } -func writeMergedGitAttributes(existing []byte, changed map[string]string, dryRun bool) error { +func writeMergedGitAttributes(attributesPath string, existing []byte, changed map[string]string, dryRun bool) error { if dryRun { return nil } @@ -223,7 +231,7 @@ func writeMergedGitAttributes(existing []byte, changed map[string]string, dryRun if content != "" { content += "\n" } - if err := os.WriteFile(".gitattributes", []byte(content), 0o644); err != nil { + if err := os.WriteFile(attributesPath, []byte(content), 0o644); err != nil { return fmt.Errorf("write .gitattributes: %w", err) } return nil diff --git a/internal/gitrepo/gitattributes_tracking_test.go b/internal/gitrepo/gitattributes_tracking_test.go index cc9ca3df..82d237eb 100644 --- a/internal/gitrepo/gitattributes_tracking_test.go +++ b/internal/gitrepo/gitattributes_tracking_test.go @@ -3,6 +3,7 @@ package gitrepo import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -51,6 +52,40 @@ func TestTrackPatternsDryRunDoesNotWrite(t *testing.T) { } } +func TestTrackReadOnlyWritesRootAttributesFromSubdirectory(t *testing.T) { + repo := t.TempDir() + cmd := exec.Command("git", "init") + cmd.Dir = repo + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + subdir := filepath.Join(repo, "nested") + if err := os.Mkdir(subdir, 0o755); err != nil { + t.Fatal(err) + } + oldwd := mustChdirTrackTest(t, subdir) + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + + const path = "data/pointer" + if _, err := TrackReadOnly(context.Background(), path); err != nil { + t.Fatalf("TrackReadOnly: %v", err) + } + if _, err := os.Stat(filepath.Join(subdir, ".gitattributes")); !os.IsNotExist(err) { + t.Fatalf("expected no nested .gitattributes, stat err=%v", err) + } + contents, err := os.ReadFile(filepath.Join(repo, ".gitattributes")) + if err != nil { + t.Fatalf("read root .gitattributes: %v", err) + } + got := string(contents) + if !strings.Contains(got, "data/pointer filter=drs diff=drs merge=drs -text") { + t.Fatalf("root .gitattributes does not track pointer: %q", got) + } + if !strings.Contains(got, "data/pointer drs=ro") { + t.Fatalf("root .gitattributes does not mark pointer read-only: %q", got) + } +} + func TestListTrackedPatternsReadsGitattributes(t *testing.T) { repo := t.TempDir() oldwd := mustChdirTrackTest(t, repo) From dca5d1172e3d5088b715766ae6d955464ac907a4 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 09:46:39 -0700 Subject: [PATCH 55/66] Fix read-only gitattributes marker --- internal/gitrepo/gitattributes_routes.go | 9 ++++++--- internal/gitrepo/gitattributes_routes_test.go | 10 +++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/gitrepo/gitattributes_routes.go b/internal/gitrepo/gitattributes_routes.go index 9f954138..d7aa2d17 100644 --- a/internal/gitrepo/gitattributes_routes.go +++ b/internal/gitrepo/gitattributes_routes.go @@ -53,7 +53,7 @@ func UpsertDRSRouteLines(gitattributesPath string, mode string, patterns []strin } for _, pat := range order { - newLine := fmt.Sprintf("%s drs.route=%s", pat, mode) + newLine := fmt.Sprintf("%s drs=%s", pat, mode) if idx, ok := seen[pat]; ok { if strings.TrimSpace(lines[idx]) != newLine { lines[idx] = newLine @@ -96,8 +96,11 @@ func parseRouteLine(line string) (pattern string, mode string, ok bool) { pat := parts[0] for _, tok := range parts[1:] { - if strings.HasPrefix(tok, "drs.route=") { - val := strings.TrimPrefix(tok, "drs.route=") + // Accept the former drs.route spelling so existing rules are updated to + // the canonical drs attribute rather than duplicated. + if strings.HasPrefix(tok, "drs=") || strings.HasPrefix(tok, "drs.route=") { + val := strings.TrimPrefix(tok, "drs=") + val = strings.TrimPrefix(val, "drs.route=") val = strings.ToLower(strings.TrimSpace(val)) if val == "ro" || val == "rw" { return pat, val, true diff --git a/internal/gitrepo/gitattributes_routes_test.go b/internal/gitrepo/gitattributes_routes_test.go index c855c03a..d58717ad 100644 --- a/internal/gitrepo/gitattributes_routes_test.go +++ b/internal/gitrepo/gitattributes_routes_test.go @@ -20,7 +20,7 @@ func TestUpsertDRSRouteLinesAddNew(t *testing.T) { b, _ := os.ReadFile(p) got := string(b) - want := "scratch/** drs.route=rw\n" + want := "scratch/** drs=rw\n" if got != want { t.Fatalf("got:\n%q\nwant:\n%q", got, want) } @@ -30,7 +30,7 @@ func TestUpsertDRSRouteLinesUpdateExisting(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, ".gitattributes") - if err := os.WriteFile(p, []byte("# hi\nscratch/** drs.route=ro\n"), 0o644); err != nil { + if err := os.WriteFile(p, []byte("# hi\nscratch/** drs=ro\n"), 0o644); err != nil { t.Fatal(err) } @@ -44,7 +44,7 @@ func TestUpsertDRSRouteLinesUpdateExisting(t *testing.T) { b, _ := os.ReadFile(p) got := string(b) - want := "# hi\nscratch/** drs.route=rw\n" + want := "# hi\nscratch/** drs=rw\n" if got != want { t.Fatalf("got:\n%q\nwant:\n%q", got, want) } @@ -54,7 +54,7 @@ func TestUpsertDRSRouteLinesIdempotent(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, ".gitattributes") - if err := os.WriteFile(p, []byte("scratch/** drs.route=rw\n"), 0o644); err != nil { + if err := os.WriteFile(p, []byte("scratch/** drs=rw\n"), 0o644); err != nil { t.Fatal(err) } @@ -68,7 +68,7 @@ func TestUpsertDRSRouteLinesIdempotent(t *testing.T) { } func TestParseRouteLine(t *testing.T) { - p, m, ok := parseRouteLine("scratch/** drs.route=rw") + p, m, ok := parseRouteLine("scratch/** drs=rw") if !ok || p != "scratch/**" || m != "rw" { t.Fatalf("unexpected: ok=%v p=%q m=%q", ok, p, m) } From 7252e01e4ffd800dfda04a030da1e51358a83205 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 10:06:22 -0700 Subject: [PATCH 56/66] Persist local DRS metadata for manifest references --- cmd/addref/add-ref.go | 16 ++++++---- cmd/addref/add-ref_test.go | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/cmd/addref/add-ref.go b/cmd/addref/add-ref.go index 5273f4dd..868ef673 100644 --- a/cmd/addref/add-ref.go +++ b/cmd/addref/add-ref.go @@ -99,17 +99,13 @@ var Cmd = &cobra.Command{ os.MkdirAll(dirPath, os.ModePerm) } - oid := addRefLocalOID(drsUri, remoteName, &obj) if err := createAddRefPointer(&obj, dstPath, drsUri); err != nil { return err } if _, err := gitrepo.TrackReadOnly(cmd.Context(), args[1]); err != nil { return fmt.Errorf("track add-ref destination %s: %w", args[1], err) } - if obj.SelfUri == "" { - obj.SelfUri = drsUri - } - if err := drsobject.WriteObject(gitrepo.DRSObjectsPath, &obj, oid); err != nil { + if err := persistAddRefObject(&obj, drsUri, remoteName); err != nil { return fmt.Errorf("write source DRS metadata: %w", err) } return nil @@ -282,11 +278,21 @@ func runManifest(cmd *cobra.Command, filename string) error { if _, err := gitrepo.TrackReadOnly(cmd.Context(), e.path); err != nil { return fmt.Errorf("track add-ref destination %s: %w", e.path, err) } + if err := persistAddRefObject(&e.object, e.uri, remoteName); err != nil { + return fmt.Errorf("write source DRS metadata for %s: %w", e.path, err) + } } fmt.Fprintf(cmd.OutOrStdout(), "%d reference(s) %s\n", len(entries), map[bool]string{true: "validated", false: "added"}[dryRun]) return nil } +func persistAddRefObject(obj *drsapi.DrsObject, sourceURI string, remoteName config.Remote) error { + if obj.SelfUri == "" { + obj.SelfUri = sourceURI + } + return drsobject.WriteObject(gitrepo.DRSObjectsPath, obj, addRefLocalOID(sourceURI, remoteName, obj)) +} + // createAddRefPointer keeps the source authority in Git. The metadata written // under .git/drs is only a local cache, so a checksum-only pointer would leave // another clone unable to route hydration back to the authority that resolved diff --git a/cmd/addref/add-ref_test.go b/cmd/addref/add-ref_test.go index 8996ea23..93022405 100644 --- a/cmd/addref/add-ref_test.go +++ b/cmd/addref/add-ref_test.go @@ -1,6 +1,7 @@ package addref import ( + "bytes" "context" "net/http" "net/http/httptest" @@ -12,10 +13,12 @@ import ( "github.com/calypr/git-drs/internal/config" "github.com/calypr/git-drs/internal/drslog" + "github.com/calypr/git-drs/internal/drsobject" "github.com/calypr/git-drs/internal/gitrepo" "github.com/calypr/git-drs/internal/lfs" "github.com/calypr/git-drs/internal/remoteruntime" drsapi "github.com/calypr/syfon/apigen/client/drs" + "github.com/spf13/cobra" ) func TestCreateLfsPointer(t *testing.T) { @@ -108,6 +111,63 @@ func TestAddRefLocalOIDUsesDerivedSourceWhenSHA256Missing(t *testing.T) { } } +func TestRunManifestPersistsResolvedObjectMetadata(t *testing.T) { + const checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const sourceURI = "drs://source.example.org/object-1" + var resolvedEndpoint, resolvedObjectID string + oldGetter := newSourceDRSGetter + newSourceDRSGetter = func(endpoint string) (drsObjectGetter, error) { + resolvedEndpoint = endpoint + return fakeDRSObjectGetter{obj: drsapi.DrsObject{ + Id: "object-1", + Size: 42, + Checksums: []drsapi.Checksum{{Type: "sha256", Checksum: checksum}}, + }, gotObjectID: &resolvedObjectID}, nil + } + t.Cleanup(func() { newSourceDRSGetter = oldGetter }) + + repo := t.TempDir() + runGitCmd(t, repo, "init") + runGitCmd(t, repo, "config", "drs.default-remote", "source") + runGitCmd(t, repo, "config", "drs.remote.source.type", "local") + runGitCmd(t, repo, "config", "drs.remote.source.endpoint", "https://primary.example.org") + manifest := filepath.Join(repo, "references.tsv") + if err := os.WriteFile(manifest, []byte("drs_uri\tpath\n"+sourceURI+"\tdata/reference\n"), 0o600); err != nil { + t.Fatal(err) + } + + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repo); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + oldRemote, oldDryRun := remote, dryRun + remote, dryRun = "", false + t.Cleanup(func() { remote, dryRun = oldRemote, oldDryRun }) + + cmd := &cobra.Command{} + cmd.SetOut(&bytes.Buffer{}) + if err := runManifest(cmd, manifest); err != nil { + t.Fatalf("runManifest: %v", err) + } + obj, err := drsobject.ReadObject(gitrepo.DRSObjectsPath, checksum) + if err != nil { + t.Fatalf("read persisted manifest object: %v", err) + } + if obj.Id != "object-1" || obj.SelfUri != sourceURI { + t.Fatalf("unexpected persisted object: %+v", obj) + } + if resolvedObjectID != "object-1" { + t.Fatalf("unexpected resolved object ID: %q", resolvedObjectID) + } + if resolvedEndpoint != "https://source.example.org" { + t.Fatalf("unexpected resolver endpoint: %q", resolvedEndpoint) + } +} + func TestCreateDRSPointerPreservesSourceURI(t *testing.T) { obj := &drsapi.DrsObject{Size: 42} path := filepath.Join(t.TempDir(), "pointer") From 5d3f2c9690a5964aa62b56d4d23be605f1783d17 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 10:22:21 -0700 Subject: [PATCH 57/66] Update documentation for unified remote add. --- README.md | 6 ++++-- docs/anvil-terra-poc-presentation.md | 4 +--- docs/anvil-terra-poc.md | 11 +++++++++- docs/anvil.md | 7 ++++++- docs/bucket-mapping.md | 3 ++- docs/developer-guide.md | 4 +++- docs/e2e-modes-and-local-setup.md | 9 ++++++-- docs/installation.md | 2 +- docs/terra-anvil-drs-download-gap-adr.md | 24 ++++++++++++++-------- docs/troubleshooting.md | 26 ++++++++++++++++-------- 10 files changed, 68 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3a51ed6c..e51cfc20 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,14 @@ The cleaned CLI intentionally removed legacy commands: - `git drs download` - `git drs pull` is hydration-only - `git drs ls-files` is the local file inventory command -- `git drs remote add gen3` takes scope as `organization/project` +- the unified `git drs remote add` takes scope through + `--scope ` Example: ```bash -git drs remote add gen3 production HTAN_INT/BForePC --cred /path/to/credentials.json +git drs remote add production calypr --scope HTAN_INT/BForePC \ + --credential file:/path/to/credentials.json ``` Current command split: diff --git a/docs/anvil-terra-poc-presentation.md b/docs/anvil-terra-poc-presentation.md index 915099e4..f270938f 100644 --- a/docs/anvil-terra-poc-presentation.md +++ b/docs/anvil-terra-poc-presentation.md @@ -162,9 +162,7 @@ git drs pull -I "data/*.cram" ```bash git init -git drs remote add terra anvil \ - --drs-endpoint https://data.terra.bio \ - --auth google-adc --mode read-only +git drs remote add anvil terra --checkout hydrate # Download the TSV from AnVIL Data Explorer first. manifest=/tmp/anvil-manifest-38dc7537.tsv diff --git a/docs/anvil-terra-poc.md b/docs/anvil-terra-poc.md index 55fbfcb8..d0e83523 100644 --- a/docs/anvil-terra-poc.md +++ b/docs/anvil-terra-poc.md @@ -91,7 +91,12 @@ Each clone configures its remote in the repository-local Git config: git drs remote add anvil terra --checkout hydrate ``` -Repository-local Git config is authoritative. It is not tracked, and credential material remains in the provider credential store. +Here `anvil` is the local remote name and `terra` is the built-in endpoint +alias. The alias supplies the Terra provider, production DRS endpoint, +Google ADC authentication, and read-only behavior; the unified command does +not use the legacy `terra` subcommand, `--drs-endpoint`, or `--mode` flags. +Repository-local Git config is authoritative. It is not tracked, and +credential material remains in the provider credential store. ### First user: add and publish references @@ -379,6 +384,10 @@ Initial schema: git drs remote add anvil terra --checkout hydrate ``` +This is the unified `[name] ` form. Do not reverse the +arguments: `git drs remote add terra anvil` is the deprecated +provider-specific command shape. + Configuration rules: - Git config is the only remote configuration representation; diff --git a/docs/anvil.md b/docs/anvil.md index 6795e8ce..836f39ff 100644 --- a/docs/anvil.md +++ b/docs/anvil.md @@ -25,7 +25,12 @@ Configure the remote in repository-local Git config: git drs remote add anvil terra --checkout hydrate ``` -The command stores only public remote metadata in `.git/config`. Google ADC remains local user state. Each clone must run the command because `.git/config` is not tracked. +`anvil` is the chosen local name and `terra` is the built-in endpoint alias. +The alias selects the Terra provider, production endpoint, Google ADC, and +read-only behavior, so no provider subcommand, `--drs-endpoint`, or `--mode` +flag is needed. The command stores only public remote metadata in `.git/config`. +Google ADC remains local user state. Each clone must run the command because +`.git/config` is not tracked. ## Publish one reference diff --git a/docs/bucket-mapping.md b/docs/bucket-mapping.md index ef68dbe1..e4a42ab4 100644 --- a/docs/bucket-mapping.md +++ b/docs/bucket-mapping.md @@ -198,7 +198,8 @@ They only need: Then they can run: ```bash -git drs remote add gen3 production HTAN_INT/BForePC --cred ~/.gen3/credentials.json +git drs remote add production calypr --scope HTAN_INT/BForePC \ + --credential file:~/.gen3/credentials.json ``` That remote setup resolves the bucket mapping from the server-side scope configuration. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index facce1a7..bf65b133 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -67,7 +67,9 @@ The pre-commit cache is non-authoritative and safe to rebuild. The local DRS met ### Setup path -`git drs remote add gen3 ...` or `git drs remote add local ...` is the standard entrypoint for connecting a repository. +The provider-neutral `git drs remote add [name] ...` is the +standard entrypoint for connecting a repository. Provider selection, scope, +authentication, and credential sources are flags rather than subcommands. That path: diff --git a/docs/e2e-modes-and-local-setup.md b/docs/e2e-modes-and-local-setup.md index 36870659..593f04e5 100644 --- a/docs/e2e-modes-and-local-setup.md +++ b/docs/e2e-modes-and-local-setup.md @@ -81,7 +81,11 @@ TEST_STRICT_CLEANUP=true - HTTP basic auth via: - `TEST_LOCAL_USERNAME` + `TEST_LOCAL_PASSWORD`, or - `TEST_ADMIN_AUTH_HEADER="Authorization: Basic "` -- `git drs remote add local ... --username ... --password ...` stores local basic auth in repo config for credential-helper flows. +- The local-only harness still exercises the hidden, deprecated + `git drs remote add local ...` compatibility path because the unified command + intentionally accepts HTTPS endpoints only. `--username` and `--password` + in this test path store local basic auth for credential-helper flows; they + are not the public CLI recommended for new scripts. ## How wrapper scripts map to the main suites @@ -226,7 +230,8 @@ Cause: Check: - `TEST_SERVER_MODE=local` -- remote configured with `git drs remote add local ...` +- local test remote configured through the deprecated + `git drs remote add local ...` compatibility path described above - local auth creds provided if server requires basic auth. ### `401 Unauthorized` on `/data/upload/...` in local mode diff --git a/docs/installation.md b/docs/installation.md index 582ce425..7941fa7d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -84,7 +84,7 @@ Then move or copy the built binary somewhere on your `PATH`. Installing the binary does not configure a repository. Repository-local setup now normally happens when you run: ```bash -git drs remote add gen3 ... +git drs remote add [name] [flags] ``` ## Read Next diff --git a/docs/terra-anvil-drs-download-gap-adr.md b/docs/terra-anvil-drs-download-gap-adr.md index 55c84db7..737c4362 100644 --- a/docs/terra-anvil-drs-download-gap-adr.md +++ b/docs/terra-anvil-drs-download-gap-adr.md @@ -57,7 +57,14 @@ However, these pieces do not yet compose into a complete Terra/AnVIL workflow. ## Decision -Add first-class Terra/AnVIL DRS support as an incremental extension of the existing remote and reference workflows. The primary decision is to introduce `git drs remote add terra ...` so authentication and provider behavior are known at remote configuration time. Once a pointer or reference is associated with a Terra remote, `git-drs` can use a Terra-aware resolver to translate the DRS URI into downloadable access URLs. The main unresolved design decision is how to represent DRS URL identity in pointer files while preserving the existing SHA256-oriented cache and compatibility assumptions. +Add first-class Terra/AnVIL DRS support as an incremental extension of the +existing remote and reference workflows. The implemented CLI uses the unified +`git drs remote add [name] terra ...` form so authentication and provider +behavior are known at remote configuration time. Once a pointer or reference +is associated with a Terra remote, `git-drs` can use a Terra-aware resolver to +translate the DRS URI into downloadable access URLs. The main unresolved +design decision is how to represent DRS URL identity in pointer files while +preserving the existing SHA256-oriented cache and compatibility assumptions. ## Goals @@ -106,12 +113,12 @@ Current remote configuration is centered on Gen3/Syfon remotes with an `organiza Example target CLI: ```bash -git drs remote add terra anvil \ - --drs-endpoint https://data.terra.bio \ - --auth google-adc \ - --mode read-only +git drs remote add anvil terra --checkout hydrate ``` +The `terra` preset pins the production endpoint, `google-adc` authentication, +and read-only provider behavior. + ### Gap 2: Terra references need a remote-aware `add-ref` path Similar reference-building behavior already exists in `add-url`, and `add-ref` already has the right conceptual role for existing DRS objects. The missing piece is a Terra-aware reference flow where the command knows which remote type owns the reference and can call the matching resolver. @@ -263,7 +270,7 @@ Gregor/AnVIL repositories reference existing controlled-access data. They should Target behavior: ```bash -git drs remote add terra anvil ... --mode read-only +git drs remote add anvil terra --checkout hydrate ``` In read-only mode: @@ -290,7 +297,7 @@ Add documentation covering: ### Phase 1: Terra remote and resolver -- Add `git drs remote add terra ...`. +- Add Terra support to the unified `git drs remote add [name] terra ...` path. - Wire Terra auth/provider configuration at remote setup time. - Add a Terra-aware resolver used by reference creation and hydration. - Add provider-specific diagnostics for authorization failures. @@ -329,7 +336,8 @@ Add documentation covering: ## Open questions -- Should the canonical command be `git drs remote add terra` or a generic `git drs remote add ga4gh` with Terra-specific auth/resolution options? +- The canonical command is the unified `git drs remote add [name] terra` + preset form; the earlier provider-subcommand alternatives are superseded. - Which AnVIL/Terra endpoint should be treated as canonical for Gregor DRS discovery? - Should Martha/Terra resolution be implemented directly or invoked through Terra Notebook Utils behavior? - What should the default policy be for records that lack checksums? diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 63e30573..f951d23b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -72,7 +72,7 @@ Those changes persist in the clone. They are not something you redo per session. Examples: -- `git drs remote add gen3 ...` +- `git drs remote add [name] ...` - `git drs remote remove ...` - `git drs init` - `git drs track` @@ -292,14 +292,18 @@ If needed, inspect the detailed logs: ls -la .git/drs/ ``` -### `git drs remote add gen3` fails on bucket mapping +### `git drs remote add` fails on bucket mapping Current shape: ```bash -git drs remote add gen3 [remote-name] [--cred | --token ] +git drs remote add [remote-name] calypr --scope \ + --credential ``` +Credential sources use an explicit scheme such as `file:/path/to/key.json`, +`env:GEN3_TOKEN`, or `profile:production`; inline secrets are not accepted. + If this fails, the likely cause is missing bucket mapping for that scope. That mapping is usually steward/admin setup, not something the end user invents ad hoc. @@ -309,7 +313,8 @@ That mapping is usually steward/admin setup, not something the end user invents Refresh by re-adding the remote with a new credential file or token: ```bash -git drs remote add gen3 production HTAN_INT/BForePC --cred /path/to/new-credentials.json +git drs remote add production calypr --scope HTAN_INT/BForePC \ + --credential file:/path/to/new-credentials.json ``` You do not need to run `git drs init` again. @@ -317,14 +322,16 @@ You do not need to run `git drs init` again. What `git-drs` does automatically: - if the stored access token is expired but the stored API key is still valid, `git-drs` will attempt to refresh the access token -- if the API key itself is expired, revoked, or replaced, you need to re-run `git drs remote add gen3 ...` +- if the API key itself is expired, revoked, or replaced, re-run the unified + `git drs remote add ... --credential ` command How to think about recovery: - token expired, key still valid: - often automatic - key expired or replaced: - - rerun `git drs remote add gen3 ... --cred ...` or `--token ...` + - rerun `git drs remote add ... --credential file:` or use an + environment credential source such as `--credential env:GEN3_TOKEN` How to check what is in use: @@ -336,7 +343,9 @@ And for the underlying Gen3 profile data: - inspect `~/.gen3/gen3_client_config.ini` -If you want the least surprising fix, just re-run `git drs remote add gen3 ...` with the current credential file. That updates the stored profile and repo token plumbing in one step. +If you want the least surprising fix, re-run the unified `git drs remote add` +command with the current `--credential file:`. That updates the stored +profile and repo token plumbing in one step. ### `git drs push` fails with upload or register errors @@ -413,7 +422,8 @@ git drs pull -I "*.bam" If you want Git DRS to always download and hydrate all file payloads automatically during checkouts (reverting to non-skip behavior), you can: - Configure it during remote setup: ```bash - git drs remote add gen3 public HTAN_INT/BForePC --no-skip-smudge + git drs remote add public calypr --scope HTAN_INT/BForePC \ + --credential file:~/.gen3/credentials.json --checkout hydrate ``` - Or configure it directly in Git settings: ```bash From 427288bff7c2785b8bfaebc7f9549b8b990fc71d Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 11:01:19 -0700 Subject: [PATCH 58/66] Fix AnVIL HTTPS access selection --- internal/resolver/resolver.go | 22 ++++++++++++++++++++-- internal/resolver/resolver_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 2cea6340..928217c2 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -41,21 +41,34 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string } var access *ResolvedAccess for _, method := range obj.AccessMethods { + // AnVIL objects commonly advertise a Google Storage (gs://) method + // before their HTTPS method. The downloader uses net/http, so selecting + // the first non-empty URL can otherwise fail with an opaque transport + // error instead of trying the usable signed HTTPS URL. + methodType := strings.ToLower(strings.TrimSpace(method.Type)) + if methodType != "http" && methodType != "https" { + continue + } switch { case method.AccessURL != nil && strings.TrimSpace(method.AccessURL.URL) != "": - access = method.AccessURL + if isHTTPAccessURL(method.AccessURL.URL) { + access = method.AccessURL + } case strings.TrimSpace(method.AccessID) != "": access, err = r.GetAccess(ctx, drsURI, method.AccessID) if err != nil { return err } + if !isHTTPAccessURL(access.URL) { + access = nil + } } if access != nil { break } } if access == nil { - return fmt.Errorf("AnVIL DRS object has no supported access method") + return fmt.Errorf("AnVIL DRS object has no supported HTTP(S) access method") } if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { return err @@ -130,6 +143,11 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string return os.Rename(tmpName, destination) } +func isHTTPAccessURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") && u.User == nil +} + type AccessMethod struct { Type string `json:"type"` AccessID string `json:"access_id,omitempty"` diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 42585b13..ab0c6561 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -117,7 +117,7 @@ func TestDownloadToCacheSelectsUsableAccessMethodAfterFirst(t *testing.T) { resolverServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/ga4gh/drs/v1/objects/object-1": - _, _ = w.Write([]byte(`{"id":"object-1","size":4,"access_methods":[{"type":"ftp"},{"type":"https","access_id":"a2"}]}`)) + _, _ = w.Write([]byte(`{"id":"object-1","size":4,"access_methods":[{"type":"gs","access_url":{"url":"gs://anvil-bucket/object-1"}},{"type":"https","access_id":"a2"}]}`)) case "/ga4gh/drs/v1/objects/object-1/access/a2": _, _ = fmt.Fprintf(w, `{"url":%q}`, download.URL) default: @@ -136,6 +136,32 @@ func TestDownloadToCacheSelectsUsableAccessMethodAfterFirst(t *testing.T) { } } +func TestDownloadToCacheRejectsNonHTTPAccessURLFromHTTPSMethod(t *testing.T) { + r := &staticResolver{object: &ResolvedObject{ + Size: 4, + AccessMethods: []AccessMethod{{ + Type: "https", + AccessURL: &ResolvedAccess{URL: "gs://anvil-bucket/object-1"}, + }}, + }} + err := DownloadToCache(context.Background(), r, "drs://example.org/object-1", filepath.Join(t.TempDir(), "cache", "object-1")) + if err == nil || !strings.Contains(err.Error(), "no supported HTTP(S) access method") { + t.Fatalf("expected unsupported access method error, got %v", err) + } +} + +type staticResolver struct { + object *ResolvedObject +} + +func (r *staticResolver) GetObject(context.Context, string) (*ResolvedObject, error) { + return r.object, nil +} + +func (r *staticResolver) GetAccess(context.Context, string, string) (*ResolvedAccess, error) { + return nil, errors.New("unexpected GetAccess call") +} + func TestDownloadToCacheValidatesSHA256BeforePromotion(t *testing.T) { const body = "data" correctSum := sha256.Sum256([]byte(body)) From ff9da80a793fee3b3e06d2a8026582cc63f5bc47 Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 11:20:46 -0700 Subject: [PATCH 59/66] Fix AnVIL HTTPS access selection --- internal/resolver/resolver.go | 10 +++------- internal/resolver/resolver_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 928217c2..a856e0c5 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -42,13 +42,9 @@ func DownloadToCache(ctx context.Context, r Resolver, drsURI, destination string var access *ResolvedAccess for _, method := range obj.AccessMethods { // AnVIL objects commonly advertise a Google Storage (gs://) method - // before their HTTPS method. The downloader uses net/http, so selecting - // the first non-empty URL can otherwise fail with an opaque transport - // error instead of trying the usable signed HTTPS URL. - methodType := strings.ToLower(strings.TrimSpace(method.Type)) - if methodType != "http" && methodType != "https" { - continue - } + // before their HTTPS method. Select by the resolved URL scheme rather + // than the optional method type: older and test resolvers may omit type, + // while the actual URL is authoritative for the net/http downloader. switch { case method.AccessURL != nil && strings.TrimSpace(method.AccessURL.URL) != "": if isHTTPAccessURL(method.AccessURL.URL) { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index ab0c6561..1b786d85 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -150,8 +150,29 @@ func TestDownloadToCacheRejectsNonHTTPAccessURLFromHTTPSMethod(t *testing.T) { } } +func TestDownloadToCacheAcceptsHTTPAccessWithOmittedMethodType(t *testing.T) { + const body = "data" + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer download.Close() + + r := &staticResolver{ + object: &ResolvedObject{ + Size: int64(len(body)), + AccessMethods: []AccessMethod{{AccessID: "access-1"}}, + }, + access: &ResolvedAccess{URL: download.URL}, + } + destination := filepath.Join(t.TempDir(), "cache", "object-1") + if err := DownloadToCache(context.Background(), r, "drs://example.org/object-1", destination); err != nil { + t.Fatalf("DownloadToCache returned error: %v", err) + } +} + type staticResolver struct { object *ResolvedObject + access *ResolvedAccess } func (r *staticResolver) GetObject(context.Context, string) (*ResolvedObject, error) { @@ -159,6 +180,9 @@ func (r *staticResolver) GetObject(context.Context, string) (*ResolvedObject, er } func (r *staticResolver) GetAccess(context.Context, string, string) (*ResolvedAccess, error) { + if r.access != nil { + return r.access, nil + } return nil, errors.New("unexpected GetAccess call") } From 896649022a5c1f29138ba58e95cfb7d4ff2777ea Mon Sep 17 00:00:00 2001 From: Brian Walsh Date: Wed, 29 Jul 2026 11:57:02 -0700 Subject: [PATCH 60/66] 1 of 2 update presentation --- .github/workflows/pages.yaml | 1 + .gitignore | 3 +++ docs/anvil-data-explorer.png | Bin 0 -> 524958 bytes docs/anvil-terra-poc-presentation.md | 6 ++++-- docs/github-pages.md | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 docs/anvil-data-explorer.png diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml index 0d7c915d..edbe1b95 100644 --- a/.github/workflows/pages.yaml +++ b/.github/workflows/pages.yaml @@ -47,6 +47,7 @@ jobs: cp docs/adding-s3-files.md .pages-docs/ cp docs/remove-files.md .pages-docs/ cp docs/troubleshooting.md .pages-docs/ + cp docs/*.png .pages-docs/ - name: Build documentation and presentation run: | diff --git a/.gitignore b/.gitignore index 16da783b..689888a8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ admin/ # ignore working dirs, files attic/ patch.txt +.pages-docs/ +_site/ + diff --git a/docs/anvil-data-explorer.png b/docs/anvil-data-explorer.png new file mode 100644 index 0000000000000000000000000000000000000000..65adf8aeab60b3fcb0cba487d181345e9630ea70 GIT binary patch literal 524958 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úo49WFoK=p!&`#3zPS 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 WS3`53Pnu*Tqic1dWgP@#o7P3!5yf8=P{~W0P+L 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

ReW!+-j_4ei`{l0%H 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 z

m&WjhXsWte`Q2 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