All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
Sungrow's OAuth server keeps only one active token session per (app, account), not per plant -- with Kallangur and McDowall each managing their own independent token against the same account, whichever one refreshed last was silently invalidating the other's refresh token. Both instances now share one token file (a common bind-mounted volume) instead of each holding their own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
58 lines
1.7 KiB
Python
58 lines
1.7 KiB
Python
"""File-based OAuth token store shared across multiple instances of this
|
|
app that authenticate against the same iSolarCloud account (see the
|
|
comment on `Settings.isolarcloud_shared_token_path`).
|
|
|
|
Uses `flock` on the shared file itself as the lock, not a separate lock
|
|
file -- `flock` is a host-kernel-level advisory lock keyed on the
|
|
underlying inode, so it works across container boundaries as long as
|
|
every instance bind-mounts the *same* host file (not independent copies).
|
|
The lock is held for the whole read-check-refresh-write sequence in
|
|
`oauth_client._get_access_token`, not just the file I/O, so two instances
|
|
waking up at the same moment can't both refresh against the
|
|
soon-to-be-superseded refresh token.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
|
|
|
|
def enabled() -> bool:
|
|
return bool(settings.isolarcloud_shared_token_path)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def locked() -> Iterator[int]:
|
|
path = Path(settings.isolarcloud_shared_token_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
yield fd
|
|
finally:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
os.close(fd)
|
|
|
|
|
|
def read(fd: int) -> dict | None:
|
|
os.lseek(fd, 0, os.SEEK_SET)
|
|
raw = os.read(fd, 1_000_000)
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def write(fd: int, data: dict) -> None:
|
|
os.lseek(fd, 0, os.SEEK_SET)
|
|
os.ftruncate(fd, 0)
|
|
os.write(fd, json.dumps(data).encode())
|