Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
1271ff8
feat(config): add named VOSpace services
shinybrar Jul 22, 2026
9c51603
refactor(config): address VOSpace review findings
shinybrar Jul 22, 2026
9de7bcc
fix(config): validate normalized storage names
shinybrar Jul 22, 2026
690f3bd
test(config): isolate storage validation tests
shinybrar Jul 22, 2026
d699d79
Merge pull request #216 from opencadc/issue-151-vospace-config
shinybrar Jul 22, 2026
5f75b13
fix(config): validate raw storage names
shinybrar Jul 22, 2026
639c245
feat(storage): materialize authenticated vosfs sources
shinybrar Jul 22, 2026
9cb8d6c
fix(storage): address authenticated source review
shinybrar Jul 22, 2026
878f2a5
test(storage): isolate authenticated source config
shinybrar Jul 22, 2026
8ecdabe
Merge pull request #218 from opencadc/feat/vosfs-authenticated-sources
shinybrar Jul 22, 2026
d69de91
feat(discovery): discover primary VOSpace services
shinybrar Jul 22, 2026
9fcfde9
test(discovery): isolate storage inspection config
shinybrar Jul 22, 2026
5ce4e6f
Merge pull request #219 from opencadc/feat/vosfs-registry-records
shinybrar Jul 22, 2026
29a49dc
fix(storage): preserve environment credential precedence
shinybrar Jul 22, 2026
c8f0924
fix(discovery): carry storage failures into activation
shinybrar Jul 22, 2026
4cdd1bf
Merge pull request #220 from opencadc/fix/vosfs-wave2-review
shinybrar Jul 22, 2026
7193c69
feat(data): embed upstream fsspec commands
shinybrar Jul 22, 2026
0ef59c9
docs(data): remove obsolete optional install
shinybrar Jul 22, 2026
f8ef7ad
test(data): cover mapped source delegation
shinybrar Jul 22, 2026
d12b197
docs(data): explain explicit cross-source copy
shinybrar Jul 22, 2026
e45debe
Merge pull request #221 from opencadc/feat/vosfs-data-cli
shinybrar Jul 22, 2026
3ec51a3
docs(data): use default CADC storage name
shinybrar Jul 22, 2026
7feaa71
Merge pull request #222 from opencadc/fix/vosfs-wave3-docs
shinybrar Jul 22, 2026
f919b3c
docs(data): document supported transfer workflows
shinybrar Jul 22, 2026
6b84518
test(data): close integration validation gaps
shinybrar Jul 22, 2026
a888015
docs(data): clarify credential and removal policy
shinybrar Jul 22, 2026
5db3df9
docs(data): clarify source mapping lifecycle
shinybrar Jul 22, 2026
e41441a
Merge pull request #223 from opencadc/docs/vosfs-data-integration
shinybrar Jul 22, 2026
48306d7
docs(data): align VOSpace credential ownership
shinybrar Jul 22, 2026
4288f3d
test(data): prove source rebuilding and freshness
shinybrar Jul 22, 2026
7b335ae
Merge pull request #224 from opencadc/fix/vosfs-wave4-review
shinybrar Jul 22, 2026
e06c6eb
fix(server): make storage inspection stateless
shinybrar Jul 23, 2026
87d7cf6
refactor(data): simplify source authentication failure
shinybrar Jul 23, 2026
1f3cfbe
fix(server): isolate registry discovery evidence
shinybrar Jul 23, 2026
ac9a0be
refactor(server): isolate discovery worker state
shinybrar Jul 23, 2026
afb822f
fix(server): preserve environment credentials
shinybrar Jul 23, 2026
e2bf7b9
Merge pull request #225 from opencadc/fix/vosfs-mega-review
shinybrar Jul 23, 2026
94c3b51
fix(ci): support pinned ty checks
shinybrar Jul 23, 2026
92b5535
Merge pull request #226 from opencadc/fix/vosfs-ci-gate
shinybrar Jul 23, 2026
dc5fc5f
test: support Python 3.10 TOML parsing
shinybrar Jul 23, 2026
eceb610
Merge pull request #227 from opencadc/fix/vosfs-python310-test
shinybrar Jul 23, 2026
cb36468
fix(auth): reuse valid CADC proxy certificates
shinybrar Jul 23, 2026
db11003
refactor(discovery): use pydantic models and concise names
shinybrar Jul 25, 2026
fe175f6
feat(storage): map arc and vault VOSpace Services for CADC
shinybrar Jul 25, 2026
8a77ffc
build(deps): upgrade vosfs to v0.7.0 and fsspec-cli to v0.6.0
shinybrar Jul 25, 2026
265b585
docs(data): correct storage names and verified command behaviour
shinybrar Jul 25, 2026
20760e9
fix(tests): assert recursion policy without rich-rendered text
shinybrar Jul 25, 2026
5018104
perf(storage): cache VOSpace directory listings per command
shinybrar Jul 25, 2026
63c6f72
docs(data): document listing, file, and byte-range caching
shinybrar Jul 25, 2026
ba5493f
refactor(discovery,cli): shorten Enrichment and group names
shinybrar Jul 25, 2026
511199f
docs(data): call the operand prefix an identifier
shinybrar Jul 25, 2026
b26261f
docs(data): correct the byte-range caching guidance
shinybrar Jul 25, 2026
dfe3680
docs(data): separate client and service byte-range limits
shinybrar Jul 25, 2026
6bfd2d2
refactor(storage): unify on storage and Storage Identifier
shinybrar Jul 25, 2026
14dadf8
build(deps): upgrade vosfs to v0.8.0 and fsspec-cli to v0.7.0
shinybrar Jul 25, 2026
39286e4
docs(client): add a Data Access guide for the Python client
shinybrar Jul 25, 2026
239861d
feat(storage): expose Storage Identifiers as importable filesystems
shinybrar Jul 25, 2026
54a5fc6
refactor(storage): address standards review across the vosfs integration
shinybrar Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ _Avoid_: Base URL, host, cluster
Required, unique, user-facing handle for one **Science Platform Server**, used to reference it in configuration and commands.
_Avoid_: Label, alias, key

**VOSpace Service**:
Remote astronomical storage service associated with a **Science Platform Server**.
_Avoid_: Storage backend, filesystem

**Storage Identifier**:
Required, globally unique, user-facing handle for one **VOSpace Service**, used to address it in configuration, commands, and operands such as `arc:/home/user`. The reserved identifier `local` addresses the machine where the command runs.
_Avoid_: Storage Name, Server Name, alias, key

**Identity Provider (IDP)**:
Organization that issues user identity for CANFAR authentication.
Initial IDPs are `Canadian Astronomy Data Centre (CADC)` and `SKA Regional Centre Network (SRCNet)`.
Expand Down Expand Up @@ -69,6 +77,8 @@ _Avoid_: Resource profile, quota

- A **CANFAR Science Platform** exposes one or more **Science Platform Servers**.
- A **Science Platform Server** is identified by its **Server Name**; its IVOA URI is discovery metadata, and two Server Names may point at the same endpoint.
- A **Science Platform Server** can expose multiple **VOSpace Services**.
- A **VOSpace Service** is identified by its **Storage Identifier** and uses its parent **Science Platform Server**'s **Identity Provider (IDP)**.
- An **Identity Provider (IDP)** can support one or more **Science Platform Servers**.
- **Authentication** and **Platform** are separate seams with independent ownership.
- An **Authentication Record** belongs to one **Identity Provider (IDP)**.
Expand Down
65 changes: 64 additions & 1 deletion canfar/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import time
from collections.abc import Awaitable, Callable, Generator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast

import httpx
from authlib.integrations.base_client.errors import OAuthError
Expand All @@ -20,6 +20,8 @@
if TYPE_CHECKING:
from authlib.integrations.httpx_client import AsyncOAuth2Client

from canfar.models.config import Configuration

log = get_logger(__name__)
_BASIC_AUTH_METHOD = "client_secret_basic"

Expand Down Expand Up @@ -193,6 +195,67 @@ def _validated_refresh(refreshed: Any) -> dict[str, Any]:
return dict(refreshed)


def _refresh(
credential: OIDCCredential,
) -> tuple[str, str, str, str] | None:
"""Return complete literal refresh inputs when the record is eligible."""
if not credential.refreshable:
return None
return (
cast("str", credential.endpoints.token),
cast("str", credential.client.identity),
cast("SecretStr", credential.client.secret).get_secret_value(),
cast("SecretStr", credential.token.refresh).get_secret_value(),
)


def _persist(
config: Configuration,
credential: OIDCCredential,
refreshed: dict[str, Any],
) -> OIDCCredential:
"""Validate and atomically persist refreshed OIDC state."""
previous_refresh = credential.token.refresh
previous_refresh_value = (
previous_refresh.get_secret_value() if previous_refresh is not None else None
)
returned_refresh = refreshed.get("refresh_token")
refresh_value = (
returned_refresh
if isinstance(returned_refresh, str) and returned_refresh
else previous_refresh_value
)
access_value = refreshed.get("access_token")
if not isinstance(access_value, str) or not access_value:
msg = "OIDC token refresh failed: malformed token response"
raise ValueError(msg)
try:
token = Token(
access=SecretStr(access_value),
refresh=SecretStr(refresh_value) if refresh_value is not None else None,
token_type=refreshed.get("token_type") or credential.token.token_type,
scope=refreshed.get("scope") or credential.token.scope,
)
expiry = Expiry(
access=refreshed.get("expires_at"),
refresh=(
None
if refresh_value != previous_refresh_value
else credential.expiry.refresh
),
)
except (KeyError, TypeError, ValidationError):
msg = "OIDC token refresh failed: malformed token response"
raise ValueError(msg) from None

updated = credential.model_copy(update={"token": token, "expiry": expiry})
candidate = config.model_copy(deep=True)
candidate.update_credential(updated)
candidate.save()
config.update_credential(updated)
return updated


async def refresh(
url: str,
identity: str,
Expand Down
12 changes: 11 additions & 1 deletion canfar/auth/x509.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
class CertificateError(ValueError):
"""Raised when an X.509 certificate cannot be used."""

def __init__(
self,
message: str,
*,
expired_at: datetime | None = None,
) -> None:
"""Initialize a certificate error with optional structured expiry."""
super().__init__(message)
self.expired_at = expired_at


def _to_utc(value: datetime) -> datetime:
"""Return timezone aware datetime.
Expand Down Expand Up @@ -88,7 +98,7 @@ def assert_valid_dates(
f"Certificate {destination} expired on {valid_until.isoformat()}; "
f"current time {now_utc.isoformat()}."
)
raise CertificateError(msg)
raise CertificateError(msg, expired_at=valid_until)


def gather(
Expand Down
60 changes: 60 additions & 0 deletions canfar/cli/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Mount the upstream data command application."""

from __future__ import annotations

from typing import TYPE_CHECKING

import typer
from fsspec_cli import App
from typer.core import TyperGroup
from typer.main import get_group

from canfar.storage import sources

if TYPE_CHECKING:
from typer._click.core import Command, Context

_DATA_GROUP_META_KEY = "canfar.data_group"


def group() -> TyperGroup:
"""Build the released upstream application with CANFAR policy.

Returns:
TyperGroup: The upstream command group bound to configured sources.
"""
return get_group(
App(
sources(),
capabilities={"recursion": {"copy": True, "remove": False}},
).typer_app
)


class _DataGroup(TyperGroup):
"""Resolve the embedded app lazily so imports perform no configuration I/O."""

@staticmethod
def _delegate(ctx: Context) -> TyperGroup:
resolved = ctx.meta.get(_DATA_GROUP_META_KEY)
if resolved is None:
resolved = group()
ctx.meta[_DATA_GROUP_META_KEY] = resolved
assert isinstance(resolved, TyperGroup)
return resolved

def list_commands(self, ctx: Context) -> list[str]:
"""List the unchanged upstream commands."""
return self._delegate(ctx).list_commands(ctx)

def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
"""Resolve an unchanged upstream command."""
return self._delegate(ctx).get_command(ctx, cmd_name)


data = typer.Typer(
cls=_DataGroup,
help="Operate on configured data sources.",
add_completion=False,
no_args_is_help=True,
)
2 changes: 1 addition & 1 deletion canfar/cli/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _login_flow(

idp_info = get_idp(idp)
try:
credential = authenticate_for_cli(idp_info, timeout=timeout)
credential = authenticate_for_cli(idp_info, timeout=timeout, force=force)
except (ValueError, RuntimeError) as exc:
get_console(stderr=True).print(f"[bold red]{exc}[/bold red]")
raise typer.Exit(1) from exc
Expand Down
28 changes: 25 additions & 3 deletions canfar/cli/login_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import webbrowser
from typing import TYPE_CHECKING, Any

import humanize
import segno
from rich import progress as rich_progress

Expand Down Expand Up @@ -98,12 +99,14 @@ def authenticate_for_cli(
idp_info: IdpInfo,
*,
timeout: int | None = None,
force: bool = False,
) -> AuthenticationCredential:
"""Acquire Authentication credentials interactively for CLI login.

Args:
idp_info: Built-in Identity Provider metadata.
timeout: HTTP timeout in seconds for OIDC requests.
force: Obtain a new X.509 certificate instead of reusing an existing one.

Returns:
Saved-ready authentication credential without embedded server.
Expand All @@ -113,19 +116,38 @@ def authenticate_for_cli(
RuntimeError: If OIDC discovery URL is missing for an OIDC IDP.
"""
if idp_info.auth_mode == "x509":
return _authenticate_x509(idp_info.key)
return _authenticate_x509(idp_info.key, force=force)
return _authenticate_oidc(idp_info, timeout=timeout)


def _authenticate_x509(idp: str) -> X509Credential:
"""Run interactive X509 certificate acquisition.
def _authenticate_x509(idp: str, *, force: bool = False) -> X509Credential:
"""Reuse a valid X.509 certificate or acquire a replacement.

Args:
idp: Canonical Identity Provider key.
force: Obtain a new certificate without inspecting the existing one.

Returns:
X509 credential record for persisted configuration.
"""
if not force:
try:
info = x509.inspect()
except x509.CertificateError as exc:
if exc.expired_at is not None:
age = humanize.naturaltime(exc.expired_at)
console_utils.get_console().print(
f"[yellow]x509 certificate expired {age}[/yellow]"
)
except (FileNotFoundError, PermissionError, ValueError):
pass
else:
return X509Credential(
idp=idp,
path=info["path"],
expiry=float(info["expiry"] or 0.0),
)

return x509.authenticate_credential(X509Credential(idp=idp, expiry=0.0))


Expand Down
10 changes: 9 additions & 1 deletion canfar/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from canfar.cli.auth import auth
from canfar.cli.config import config
from canfar.cli.create import create
from canfar.cli.data import data
from canfar.cli.delete import delete
from canfar.cli.events import events
from canfar.cli.image import image
Expand Down Expand Up @@ -115,7 +116,8 @@ def warning_writer(error: StructuredError) -> None:
if ctx.invoked_subcommand is None:
get_console().print(ctx.get_help())
raise typer.Exit(0)
set_before_command(ctx, _emit_banner_for_command)
if ctx.invoked_subcommand != "data":
set_before_command(ctx, _emit_banner_for_command)


cli: typer.Typer = typer.Typer(
Expand Down Expand Up @@ -161,6 +163,12 @@ def warning_writer(error: StructuredError) -> None:
rich_help_panel="Auth Management",
)

cli.add_typer(
data,
name="data",
rich_help_panel="Data Management",
)

cli.add_typer(
create,
no_args_is_help=True,
Expand Down
Loading
Loading