Skip to content
Draft
21 changes: 11 additions & 10 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,20 @@ repo instead - see [Advanced](#advanced-hidden-flags) - but it bypasses the
cluster and is not the intended flow.) The access model:

- Authentication: the **Bearer token** from your kubeconfig (client
certificates do not work).
- Authorization: the ClusterRole
`d8:registry-packages-proxy:packages-download`, bound by the cluster
administrator. Authorization is cached for about 5 minutes, so after the
binding is created, retry with a fresh token.
- Endpoint: discovered automatically through your kubeconfig's API server;
override with `--rpp-endpoint` / `D8_RPP_ENDPOINT`, pass a private CA with
certificates work only on clusters whose proxy trusts the cluster CA).
- Authorization: the ClusterRole `d8:registry-packages-proxy:cli-download`,
bound by the cluster administrator. Authorization is cached for about 5
minutes, so after the binding is created, retry with a fresh token.
- Endpoint: the cluster is asked where the proxy is, and the endpoints it
offers are tried in order - master addresses first, then the public host.
Override with `--rpp-endpoint` / `D8_RPP_ENDPOINT`, pass a private CA with
`--rpp-ca-file`.

The access model is shared with d8 self-update (see
[self-update.md - How access works](self-update.md#how-access-works) for the
OIDC-kubeconfig and endpoint-discovery details), but the ClusterRole differs:
plugins need `packages-download`, CLI self-update needs `cli-download`.
OIDC-kubeconfig and endpoint-discovery details), including the ClusterRole:
plugins live under `deckhouse-cli/plugins/<name>`, so they are served from
`/v1/images/` and covered by the same `cli-download` role.

## Commands

Expand Down Expand Up @@ -134,7 +135,7 @@ The persistent flags above are shared by every `d8 plugins` subcommand; the
|---|---|---|
| `image or tag not found` (404) | that plugin - or that specific version - is not published in this cluster's registry | check with `d8 plugins versions <name>`; publishing is the plugin CI's job |
| `... unauthorized (401)` | no accepted Bearer token (a client-certificate kubeconfig is not enough) | use an OIDC-token kubeconfig (Kubeconfig Generator or `d8 login`) |
| `... forbidden (403)` | your identity may not download plugins | ask an admin to bind the ClusterRole `d8:registry-packages-proxy:packages-download`; authorization is cached ~5 min, so retry with a fresh token |
| `... forbidden (403)` | your identity may not download plugins | ask an admin to bind the ClusterRole `d8:registry-packages-proxy:cli-download`; authorization is cached ~5 min, so retry with a fresh token |
| `... requirements not satisfied` | mandatory **plugin** dependencies are missing or version-incompatible | run `d8 plugins contract <name>`; on `install` deps auto-install, but at plugin *run* time install them manually as the hint says (`d8 plugins install <dep>`) |
| `... requires Kubernetes/Deckhouse/module ...` | a **cluster-side** requirement is unmet (a different message from the row above) | upgrade the cluster/module, or pass `--skip-cluster-checks` to bypass verification |
| `... upstream error (5xx)` | the proxy could not reach the backing registry | retry shortly, or check the `registry-packages-proxy` pods in `d8-cloud-instance-manager` |
Expand Down
6 changes: 3 additions & 3 deletions docs/self-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
d8 cli update
│ Bearer token from your kubeconfig
registry-packages-proxy.<publicDomain> (found automatically via Ingress)
registry-packages-proxy (address taken from the cluster)
│ TokenReview + SubjectAccessReview (kube-rbac-proxy)
cluster registry (credentials live only inside the cluster)
Expand Down Expand Up @@ -137,7 +137,7 @@ $ d8 cli use v0.13.0 # repeated: "deckhouse-cli is already at v0.13.0
| `... unauthorized` (401) | no token in kubeconfig, or a client-certificate identity | use an OIDC kubeconfig from the Kubeconfig Generator |
| `... forbidden` (403) | the `cli-download` role is not bound to you | ask the administrator for the ClusterRoleBinding |
| 403 right after the role was bound | the proxy caches authorization for ~5 min per token | retry with a fresh token or wait 5 minutes |
| `x509: certificate signed by unknown authority` | the proxy endpoint uses a CA your system does not trust | pass `--rpp-ca-file <ca.pem>` |
| `x509: ... doesn't contain any IP SANs` | you are connecting to a pod IP instead of the Ingress host | set `--rpp-endpoint https://registry-packages-proxy.<publicDomain>` |
| `x509: certificate signed by unknown authority` | the endpoint you named with `--rpp-endpoint` uses a CA your system does not trust | pass `--rpp-ca-file <ca.pem>`; discovered endpoints already use the CA the cluster publishes |
| `no endpoint answered` | every endpoint the cluster offered was rejected, and the message names each one with its reason | master addresses need network access to port 4219, the public host needs a valid certificate; or name an endpoint yourself with `--rpp-endpoint` |
| `deckhouse-cli is already up to date` | you run the latest version | use `--version X` to install an exact (older) one |
| `d8 cli use X` downloads although X was installed before | the local store was cleaned, or X was installed on another machine/user | it will download once and stay installed |
8 changes: 5 additions & 3 deletions internal/plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ The `pluginSource` interface (`source.go`) has two implementations, chosen in
side (ADR #386: deckhouse-cli reaches the registry exclusively through the
proxy, so every command needs a reachable cluster). See
`internal/selfupdate/README.md` for what RPP is and how authorization works -
plugin download is gated by the `d8:registry-packages-proxy:packages-download`
ClusterRole, distinct from self-update's `cli-download`. The plugin routes are
`/v1/images/deckhouse-cli/plugins/<name>/{tags,manifests/<ref>,images/<version>}`.
plugin download is gated by the `d8:registry-packages-proxy:cli-download`
ClusterRole, the same one self-update needs. The plugin routes are
`/v1/images/deckhouse-cli/plugins/<name>/{tags,manifests/<ref>,images/<version>}`,
and kube-rbac-proxy authorizes the whole `/v1/images/` prefix through the
`deployments/cli-binary` subresource.
- **`registryPluginSource` (`source_legacy.go`) - a temporary, hidden `--source`
bypass.** It pulls straight from a registry repo with go-containerregistry,
skipping the proxy and the cluster, and force-sets `--skip-cluster-checks`. It
Expand Down
4 changes: 4 additions & 0 deletions internal/plugins/cmd/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ func newContractCommand(manager *plugins.Manager, logger *dkplog.Logger) *cobra.

ctx := cmd.Context()

if err := manager.InitPluginServices(ctx); err != nil {
return err
}

latestVersion, err := manager.LatestVersion(ctx, pluginName)
if err != nil {
return fmt.Errorf("failed to fetch latest version: %w", err)
Expand Down
20 changes: 12 additions & 8 deletions internal/plugins/cmd/errdetect/diagnose.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ func Diagnose(err error) *diagnostic.HelpfulError {
switch {
case errors.Is(err, rpp.ErrUnauthorized):
return help(err, "registry-packages-proxy: unauthorized (401)",
"no accepted Bearer token (a client-certificate kubeconfig is not enough)",
"use a kubeconfig with an OIDC token (Kubeconfig Generator or 'd8 login')")
"the kubeconfig credential was rejected: an invalid or expired token, or a client certificate the proxy does not trust",
"use a kubeconfig with an OIDC token",
"a client-certificate kubeconfig works only when the certificate is signed by the cluster CA")
case errors.Is(err, rpp.ErrForbidden):
// Plugins live under deckhouse-cli/plugins/<name>, so they are served from
// /v1/images/ and authorized by cli-download. The packages-download role
// covers /v1/packages/ and grants nothing here.
return help(err, "registry-packages-proxy: forbidden (403)",
"the identity may not download plugins",
"bind the ClusterRole 'd8:registry-packages-proxy:packages-download' to the user/group",
"bind the ClusterRole 'd8:registry-packages-proxy:cli-download' to the user/group",
"authorization is cached ~5 min - after binding, retry with a fresh token")
case errors.Is(err, rpp.ErrNotFound):
return help(err, "registry-packages-proxy: plugin or version not found (404)",
Expand All @@ -53,11 +57,11 @@ func Diagnose(err error) *diagnostic.HelpfulError {
"the proxy could not reach the backing registry",
"retry shortly, or check the registry-packages-proxy pods in d8-cloud-instance-manager")
case errors.Is(err, rpp.ErrEndpointDiscovery):
return help(err, "registry-packages-proxy: endpoint discovery via the Kubernetes API failed",
"discovery reaches the proxy through your kubeconfig's API server, which was unreachable or presented an invalid certificate",
"this is the Kubernetes API endpoint (kubeconfig 'server:'), not the proxy - confirm it is reachable and its TLS certificate is valid for that host",
"skip discovery: pass --rpp-endpoint https://registry-packages-proxy.<publicDomain> (or set D8_RPP_ENDPOINT)",
"on a master node, point the kubeconfig at the local API (https://127.0.0.1:6445, CA /etc/kubernetes/pki/ca.crt) with an OIDC token")
return help(err, "registry-packages-proxy: no usable endpoint found",
"either the cluster could not be asked where the proxy is, or none of the endpoints it offered answered",
"the message above names what was tried: a Kubernetes API failure means the kubeconfig 'server:' is unreachable or its certificate is invalid",
"a per-endpoint failure means the proxy was not reachable there - master addresses need access to port 4219, the public host needs a valid certificate",
"skip discovery: pass --rpp-endpoint <url> (or set D8_RPP_ENDPOINT), adding --rpp-ca-file <ca.pem> when its CA is not publicly trusted")
default:
return nil
}
Expand Down
4 changes: 2 additions & 2 deletions internal/plugins/cmd/errdetect/diagnose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ func TestDiagnose(t *testing.T) {
wantSol string
}{
{"401", rpp.ErrUnauthorized, "unauthorized (401)", "OIDC"},
{"403", rpp.ErrForbidden, "forbidden (403)", "packages-download"},
{"403", rpp.ErrForbidden, "forbidden (403)", "cli-download"},
{"404", rpp.ErrNotFound, "plugin or version not found (404)", "deckhouse-cli/plugins"},
{"5xx", rpp.ErrUpstream, "upstream error (5xx)", "registry-packages-proxy pods"},
{"discovery", rpp.ErrEndpointDiscovery, "endpoint discovery via the Kubernetes API failed", "--rpp-endpoint"},
{"discovery", rpp.ErrEndpointDiscovery, "no usable endpoint found", "--rpp-endpoint"},
}

for _, tc := range cases {
Expand Down
4 changes: 4 additions & 0 deletions internal/plugins/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ func newInstallCommand(manager *plugins.Manager) *cobra.Command {
pluginName := args[0]
ctx := cmd.Context()

if err := manager.InitPluginServices(ctx); err != nil {
return err
}

opts := []plugins.InstallOption{
plugins.InstallWithVersion(version),
plugins.InstallWithMajorVersion(useMajor),
Expand Down
16 changes: 4 additions & 12 deletions internal/plugins/cmd/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,15 @@ func NewCommand(logger *dkplog.Logger, builtinCommands []string) *cobra.Command
" " + rppflags.EnvCAFile + " PEM CA bundle for proxy TLS verification\n" +
" KUBECONFIG path to the kubeconfig file",
Hidden: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
// The plugins directory was captured at registration time, BEFORE flag
// parsing - re-read it here so --plugins-dir is honored (the env
// path DECKHOUSE_CLI_PATH is applied earlier, at registration).
manager.SetDirectory(flags.DeckhousePluginsDir)

// init plugin services for subcommands after flags are parsed.
// PersistentPreRunE is outside wrapProxyDiagnostics, so classify
// proxy/discovery failures here too.
if err := manager.InitPluginServices(cmd.Context()); err != nil {
if diag := errdetect.Diagnose(err); diag != nil {
return diag
}

return err
}

// The proxy client is NOT built here: only the network subcommands need
// it (they call InitPluginServices themselves), and the local ones -
// list, remove - must work without a reachable cluster.
if err := manager.EnsureInstallRoot(); err != nil {
logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error()))
}
Expand Down
9 changes: 9 additions & 0 deletions internal/plugins/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func newUpdateCommand(manager *plugins.Manager) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
pluginName := args[0]

if err := manager.InitPluginServices(cmd.Context()); err != nil {
return err
}

fmt.Printf("Updating plugin: %s\n", pluginName)

return manager.InstallPlugin(cmd.Context(), pluginName, plugins.InstallWithMajorVersion(useMajor))
Expand All @@ -58,6 +63,10 @@ func newUpdateAllCommand(manager *plugins.Manager) *cobra.Command {
Short: "Update all installed plugins",
Long: "Update all installed plugins to their newest cluster-compatible version within each plugin's current major.",
RunE: func(cmd *cobra.Command, _ []string) error {
if err := manager.InitPluginServices(cmd.Context()); err != nil {
return err
}

fmt.Println("Updating all installed plugins...")

if err := manager.UpdateAll(cmd.Context()); err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/plugins/cmd/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ func newVersionsCommand(manager *plugins.Manager) *cobra.Command {
return err
}

if err := manager.InitPluginServices(cmd.Context()); err != nil {
return err
}

versions, err := manager.PublishedVersions(cmd.Context(), pluginName)
if err != nil {
return err
Expand Down
8 changes: 8 additions & 0 deletions internal/plugins/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ import (
// reaching the proxy by the user's kubeconfig identity. ctx bounds endpoint
// discovery, so a Ctrl-C during command startup is honored. The proxy is the only
// plugin source (ADR: deckhouse-cli reaches the registry exclusively through it).
//
// Each network entry point calls this before touching the source; local
// subcommands never do, so they work without a reachable cluster. A repeated
// call is a no-op, keeping the entry points independent of each other.
func (m *Manager) InitPluginServices(ctx context.Context) error {
if m.service != nil {
return nil
}

// legacy --source bypass (temporary): pull straight from a registry, skipping
// the proxy and the cluster. See internal/plugins/source_legacy.go.
if d8flags.SourceRegistryRepo != "" {
Expand Down
Loading
Loading