PowerScale Plugin Implementation
The PowerScale plugin provides storage integration for the dapcli repository, enabling the management of Isilon (PowerScale) clusters through Cloudify orchestration. It encapsulates the OneFS Platform-API (PAPI) client to handle session management, CSRF token propagation, and secure credential handling, while leveraging the DAP secret store to manage sensitive data like S3 access keys without exposing them in deployment runtime properties. The plugin defines specific Cloudify node types that utilize these components to reconcile storage resources, ensuring that cluster connections and secrets are maintained in a consistent state.
OneFS Platform-API Client
Section titled “OneFS Platform-API Client”The core communication with PowerScale clusters is handled by the PapiClient class in plugins/powerscale/src/powerscale_plugin/papi.py 1. OneFS does not accept HTTP basic authentication on real endpoints; instead, every request must travel through a session where the isicsrf cookie is echoed back as the X-CSRF-Token header, accompanied by a matching Referer header. The client manages this session lifecycle, including a single retry on 401 responses to handle silent session cookie expiration after the cluster’s idle timeout.
Credentials are encapsulated in the ClusterConnection dataclass, which includes the host, user, password, TLS verification settings, and port. These credentials are never exposed in command-line arguments, environment variables, or unredacted log output. The client uses httpx for HTTP operations and provides methods for GET, POST, PUT, and DELETE requests, automatically parsing JSON responses or returning raw responses when requested 2.
Error handling is centralized in the PapiError exception, which captures the HTTP status code, the AEC_* error code extracted from the OneFS error envelope, and the error message 1. The _extract_aec helper function parses the JSON error body to retrieve the first error’s code and message, falling back to generic HTTP error details if parsing fails 3.
Secret Store Integration
Section titled “Secret Store Integration”Sensitive data, such as S3 access keys, is managed through the SecretStore class in plugins/powerscale/src/powerscale_plugin/secrets.py 4. This class acts as a thin wrapper around the DAP orchestrator’s secret store, ensuring that minted secrets never appear in the deployment’s runtime_properties. Instead, runtime_properties carries only the secret’s key name, allowing consumers to reference the value via {get_secret: <key>}.
The SecretStore provides idempotent operations for creating, updating, and deleting secrets. The create_or_update method attempts to create a secret first; if a conflict occurs (e.g., the key already exists), it falls back to updating the existing value. This logic handles compatibility with older orchestrator client signatures that may not support the update_if_exists parameter.
The REST client for the orchestrator is acquired lazily via cloudify.manager.get_rest_client, allowing tests to patch the module-level import without requiring a real orchestrator environment. The delete method swallows “not found” errors to ensure retries converge successfully.
Cloudify Node Types and Reconciliation
Section titled “Cloudify Node Types and Reconciliation”The plugin defines Cloudify node types that utilize the PapiClient and SecretStore to manage storage resources 5. The package version is defined as “0.1.0” in plugins/powerscale/src/powerscale_plugin/__init__.py. While the specific node type definitions are not detailed in the provided sources, the reconciliation logic relies on the capabilities of the PAPI client to interact with the OneFS cluster and the secret store to manage credentials 1 4.
The reconciliation process involves establishing a secure session with the PowerScale cluster using the provided credentials, performing necessary API calls to create or modify storage resources, and updating the deployment’s runtime properties with the resulting resource identifiers or secret keys 1 4. The PapiClient ensures that all API interactions are authenticated and authorized via the session cookie mechanism, while the SecretStore ensures that sensitive data is handled securely and idempotently 1 4.
"""OneFS Platform-API (PAPI) client.
OneFS does not accept HTTP basic auth on real endpoints. Every request
must travel through a session whose ``isicsrf`` cookie is echoed back as
the ``X-CSRF-Token`` header, with a matching ``Referer``. This module
encapsulates that dance plus a single retry on 401 (the session cookie
ages out silently after the cluster's idle timeout).
Credentials are accepted in a ``ClusterConnection`` dataclass and never
appear in argv, environment, or unredacted log output.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any
import httpx
class PapiError(RuntimeError):
"""Non-2xx PAPI response. Carries the AEC_* error code if present."""
def __init__(self, status: int, code: str, message: str, path: str):
self.status = status
self.code = code
self.message = message
self.path = path
super().__init__(f"PAPI {status} {code} on {path}: {message}")
@dataclass(frozen=True)
class ClusterConnection:
host: str
user: str
password: str
verify_tls: bool = True
ca_bundle: str | None = None
csrf = client.cookies.get("isicsrf")
if not csrf:
raise PapiError(
r.status_code,
"AUTH_NO_CSRF",
"session response carried no isicsrf cookie",
self._SESSION_PATH,
)
client.headers["X-CSRF-Token"] = csrf
client.headers["Referer"] = self.base_url
def _ensure_session(self, *, force: bool = False) -> httpx.Client:
if force and self._client is not None:
self._client.close()
self._client = None
if self._client is None:
client = self._new_client()
self._login(client)
self._client = client
return self._client
# ------------------------------------------------------------------
# request surface
def request(
self,
method: str,
path: str,
*,
json_body: Any = None,
params: dict | None = None,
timeout: float | None = None,
expect_status: tuple[int, ...] | None = None,
raw_response: bool = False,
) -> Any:
"""Execute a single PAPI request with one 401 reauth retry.
Returns the parsed JSON body for 2xx responses (or None for 204).
Raises :class:`PapiError` for everything else, with the AEC_*
code extracted when available.
def _extract_aec(r: httpx.Response) -> tuple[str, str]:
"""OneFS error envelopes carry ``{errors: [{code, message}, ...]}``."""
try:
body = r.json()
except json.JSONDecodeError:
return ("HTTP_ERROR", r.text[:400] or f"HTTP {r.status_code}")
if isinstance(body, dict):
errors = body.get("errors")
if isinstance(errors, list) and errors:
first = errors[0]
if isinstance(first, dict):
return (
str(first.get("code") or "PAPI_ERROR"),
str(first.get("message") or ""),
)
return ("PAPI_ERROR", json.dumps(body)[:400])
"""Thin wrapper around the DAP orchestrator's secret store.
Used by the plugin's secret-bearing operations (currently only
``S3AccessKey``) so minted secrets never appear in the deployment's
``runtime_properties``. ``runtime_properties`` carries only the secret's
KEY NAME; consumers reference the value via ``{get_secret: <key>}``.
The Cloudify manager-side REST client is fetched lazily via
``cloudify.manager.get_rest_client``; tests patch the module-level
import to avoid escaping to a real orchestrator.
"""
from __future__ import annotations
import logging
from typing import Any
class SecretStoreError(RuntimeError):
"""Raised when a DAP secret operation cannot be completed safely."""
class SecretStore:
"""Idempotent create/update/delete for DAP secrets.
The orchestrator's secret API:
- ``client.secrets.create(key, value, visibility=...)`` -- POSTs a new
secret; raises if the key already exists unless
``update_if_exists=True``.
- ``client.secrets.update(key, value)`` -- PUTs the value.
- ``client.secrets.delete(key)`` -- DELETEs the secret. Raises if
missing.
"""
def __init__(self, *, logger: logging.Logger | None = None) -> None:
self._logger = logger
# ------------------------------------------------------------------
# rest-client acquisition is lazy so tests can patch it before any
__version__ = "0.1.0"