Compare commits

...
Sign in to create a new pull request.

18 commits

Author SHA1 Message Date
myasnikovdaniil
4f309823d2
Release v1.3.1 (#2479)
This PR prepares the release `v1.3.1`.
2026-04-29 15:02:54 +05:00
cozystack-ci[bot]
c0004e5c5a Prepare release v1.3.1
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com>
2026-04-29 04:26:30 +00:00
myasnikovdaniil
09edf16b85
[Backport release-1.3] fix(api): prevent IDOR in TenantNamespace Get and Watch handlers (#2524)
# Description
Backport of #2471 to `release-1.3`.
2026-04-28 22:41:54 +05:00
Myasnikov Daniil
5c68447763 fix(api): address review feedback on TenantNamespace watch path
- Hoist user identity extraction out of the Watch goroutine; reuse a
  cached username and groups map across events instead of re-fetching
  them per event. Watch now returns Unauthorized up front when no user
  is present in the context, rather than failing silently per event.
- Switch the per-event access-check error log to structured klog.ErrorS
  to comply with the project Go style guide.
- Strengthen TestGet_WithAccess to assert the concrete *TenantNamespace
  type plus Name, Kind, and APIVersion, so type or metadata regressions
  fail fast.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit 61ed7ad89c)
2026-04-28 14:07:29 +00:00
IvanHunters
e1f66927cb fix(api): address review feedback on TenantNamespace RBAC
Address review feedback from sircthulhu and CodeRabbit:

1. Return Forbidden instead of NotFound for unauthorized access
   - Get() now returns 403 Forbidden to follow standard K8s RBAC behavior
   - Previously returned 404 NotFound for security-by-obscurity
   - Updated test expectations to match new behavior

2. Propagate field and label selectors in Watch handler
   - Pass opts.FieldSelector and opts.LabelSelector to upstream Watch
   - Add defensive filtering before authorization to prevent RBAC bypass
   - Fixes potential issue with resourceNames restrictions

3. Refactor subject-matching logic to eliminate duplication
   - Extract matchesSubject() helper for Group/User/ServiceAccount checks
   - Remove duplicated code from filterAccessible and hasAccessToNamespace
   - Consolidates ServiceAccount namespace fallback logic

All tests pass successfully.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
(cherry picked from commit 7257b6aed4)
2026-04-28 14:07:29 +00:00
IvanHunters
79c23d895a test(api): add security tests for TenantNamespace IDOR fix
Added comprehensive unit tests for authorization logic:

- hasAccessToNamespace() tests:
  - User subject access (positive and negative cases)
  - Group subject access
  - ServiceAccount subject access
  - ServiceAccount with empty namespace (defaults to RoleBinding ns)
  - Privileged groups (system:masters, cozystack-cluster-admin)

- Get() handler tests:
  - Returns namespace when user has access
  - Returns NotFound when user lacks access (not Forbidden)
  - Returns NotFound for non-tenant namespaces

All tests verify that authorization correctly enforces RoleBinding-based
access control and prevents IDOR vulnerabilities.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
(cherry picked from commit 5b2501db91)
2026-04-28 14:07:29 +00:00
IvanHunters
82fb7990d5 fix(api): prevent IDOR in TenantNamespace Get and Watch handlers
Fixed two IDOR vulnerabilities allowing authenticated users to access
metadata of any tenant namespace without proper authorization.

Changes:
- Added hasAccessToNamespace() for efficient single-namespace access checks
- Get() now verifies access before returning namespace metadata
- Watch() filters events per-namespace with proper authorization
- Returns NotFound (not Forbidden) to prevent tenant enumeration

Performance optimization:
- hasAccessToNamespace() lists RoleBindings only in target namespace
  instead of listing all cluster RoleBindings (order of magnitude faster)
- Watch handler logs authorization errors for security audit

Additional fixes:
- Handle ServiceAccount subjects with empty namespace correctly
- Add klog error logging for failed authorization checks

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
(cherry picked from commit 64a3edff01)
2026-04-28 14:07:29 +00:00
myasnikovdaniil
5eb52f00b0
[Backport release-1.3] build(linstor): include linstor-gui in root image build target (#2518)
# Description
Backport of #2498 to `release-1.3`.
2026-04-28 15:57:04 +05:00
Myasnikov Daniil
fe577a26c6 build(linstor): use shared BUILDX_ARGS for linstor-gui image build
The per-package Makefile added in #2382 hardcoded buildx flags
(--provenance, --builder, --platform=linux/amd64,linux/arm64,
--push, --load, --label) instead of using the shared $(BUILDX_ARGS)
macro from hack/common-envs.mk.

This broke CI: the runner's default docker driver does not support
multi-platform builds, and the hardcoded multi-arch platform list
crashed `make build` with "Multi-platform build is not supported for
the docker driver."

Replace the hardcoded flags with $(BUILDX_ARGS) to match every other
package (e.g. linstor, dashboard, cilium). $(BUILDX_ARGS) injects
--push, --load, --label, --provenance=false, and only sets --builder
or --platform when the operator explicitly exports BUILDER/PLATFORM.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit a9a66bf066)
2026-04-28 10:55:42 +00:00
Myasnikov Daniil
f6da822cd0 build(linstor): include linstor-gui in root image build target
The linstor-gui package was added in #2382 with its own per-package
Makefile and Dockerfile, but the root Makefile's `build:` target was
not updated to invoke it. As a result `ghcr.io/cozystack/cozystack/
linstor-gui` has never been published (registry returns NAME_UNKNOWN)
and the chart's `image.tag` was never digest-pinned. Any cluster
deploying the chart hits ImagePullBackOff.

Wire the package into the root build alongside the other system
images. The next CI build will publish the image and the per-package
Makefile will rewrite values.yaml `image.repository`/`image.tag` to a
digest-pinned reference automatically.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit 7443e22345)
2026-04-28 10:55:42 +00:00
myasnikovdaniil
3c2aeb133e
[Backport release-1.3] fix(kamaji): increase memory limits and add startup probe (#2491)
# Description
Backport of #2421 to `release-1.3`.
2026-04-28 15:46:45 +05:00
myasnikovdaniil
c131acc8c6
[Backport release-1.3] fix(etcd): remove destructive post-upgrade cert-regeneration hook (#2511)
# Description
Backport of #2462 to `release-1.3`.
2026-04-28 15:46:34 +05:00
Myasnikov Daniil
cbc1606c41 fix(etcd): remove destructive post-upgrade cert-regeneration hook
The etcd chart shipped a `post-upgrade` Helm hook that `kubectl delete`d the
etcd TLS chain (`etcd-{ca,peer-ca,client,peer,server}-tls`) and then deleted
etcd pods on every chart upgrade, gated by a semver compare of an
`etcd-deployed-version` ConfigMap against `2.6.1`.

The hook was added as a one-shot migration for the chart `2.6.0 -> 2.6.1`
transition. Since commit f871fbdb ("Remove versions_map logic") all chart
versions are stamped as `0.0.0+<git-hash>`, which per semver is always
`< 2.6.1`. The gate therefore always resolves to "update certs", firing the
destructive hook on every etcd upgrade. On clusters running Kamaji-managed
tenant control planes this wipes the etcd CA, cert-manager re-issues it, and
tenant kube-apiservers hit `x509: certificate signed by unknown authority`
against `etcd.<ns>.svc:2379` until each tenant DataStore is manually
re-reconciled.

Commit 47d81f70 ("Disabled private key rotation in CA certs") already fixed
the underlying `rotationPolicy: Always` issue the migration was papering
over, so the hook has no remaining use. Remove the hook Job, its RBAC, the
version ConfigMap it read, and add a helm-unittest suite under
`packages/extra/etcd/tests/` that guards against re-introducing the hook
or the version ConfigMap.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit c1508940bd)
2026-04-28 09:37:16 +00:00
Andrei Kvapil
f0f3b18add
[Backport release-1.3] feat(linstor): bump linstor-csi to v1.10.6 with Protocol-C dual-attach fix (#2505)
# Description
Backport of #2496 to `release-1.3`.
2026-04-27 20:51:25 +02:00
Andrei Kvapil
f384be5475 feat(linstor): bump linstor-csi to v1.10.6 with Protocol-C dual-attach fix
Bump linstor-csi to v1.10.6 and add an out-of-tree patch that overrides
DrbdOptions/Net/protocol to C on the resource-definition whenever a
volume is attached to a second node with allow-two-primaries (the
live-migration flow), and reverts it on detach.

Without the patch, any KubeVirt live migration of a VM whose volume
sits on a Protocol-A/B resource (for example a "replicated-async"
StorageClass) ends up in a permanent evacuation loop: drbdadm adjust
on the satellite rejects allow-two-primaries with "Protocol C
required" (errno 139), the migration fails, and KubeVirt retries
until manual intervention.

The override is set at the resource-definition level so it covers
every connection, including diskless TieBreaker peers, and is tagged
with an Aux marker so Detach reverts only the override we installed.
The 001 relocate-after-clone patch was regenerated against v1.10.6
(context shift only, no logic change).

Verified end-to-end on dev5: live migration of a KubeVirt VM whose
PVC sits on a Protocol-A resource-group now succeeds in a single
Attach.

Patch is upstreamed as draft PR piraeusdatastore/linstor-csi#435.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
(cherry picked from commit 073fb1630d)
2026-04-27 18:50:53 +00:00
IvanHunters
ed89553023 fix(kamaji): increase memory limits and add startup probe
- Increase memory limit from 500Mi to 512Mi
- Increase memory request from 100Mi to 256Mi
- Add startup probe with 60s timeout (12 attempts × 5s)
- Increase readiness/liveness initialDelaySeconds from 5/15 to 30s

This fixes OOMKilled crashes observed in production where kamaji
controller was being killed due to insufficient memory during startup.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
(cherry picked from commit e148343fd9)
2026-04-24 20:12:10 +00:00
myasnikovdaniil
41bcb0becb
[Backport release-1.3] fix(backups): move velero-configmap Role to velero chart (#2467)
## Summary

Backport of #2459 to release-1.3.

Fixes installation failure of `backupstrategy-controller` in bundles
without velero.

## Changes

- Move velero-configmap Role/RoleBinding from backupstrategy-controller
chart to velero chart
- Prevents "namespaces \"cozy-velero\" not found" error when velero is
not installed

## Original Commit

Cherry-pick of c4477259c7 from main.
2026-04-23 17:08:04 +05:00
Myasnikov Daniil
5329dd180b fix(backups): move velero-configmap Role to velero chart
The backupstrategy-controller chart declared a Role/RoleBinding in the
cozy-velero namespace for ResourceModifier ConfigMap management. Because
velero is an optional package, that namespace does not exist in bundles
without velero, so Helm install aborted with "namespaces \"cozy-velero\"
not found" and blocked the default install of backupstrategy-controller.

Move the Role and RoleBinding into the velero chart so they are created
only when velero is actually installed. The RoleBinding subject points
to the backupstrategy-controller ServiceAccount in its fixed namespace
(cozy-backup-controller).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-23 15:04:43 +03:00
42 changed files with 874 additions and 235 deletions

View file

@ -22,6 +22,7 @@ build: build-deps
make -C packages/system/lineage-controller-webhook image
make -C packages/system/cilium image
make -C packages/system/linstor image
make -C packages/system/linstor-gui image
make -C packages/system/kubeovn-webhook image
make -C packages/system/kubeovn-plunger image
make -C packages/system/dashboard image

View file

@ -1 +1 @@
ghcr.io/cozystack/cozystack/kubevirt-csi-driver:0.0.0@sha256:72154a97054e16cdf3dea6129d962b8d7e86b55cf9386095e8ac2ce7c8b69172
ghcr.io/cozystack/cozystack/kubevirt-csi-driver:0.0.0@sha256:0e70f4391b344c71a9567e1ed22ca24910116a619cb681381059b8c39f382219

View file

@ -1,9 +1,9 @@
cozystackOperator:
# Deployment variant: talos, generic, hosted
variant: talos
image: ghcr.io/cozystack/cozystack/cozystack-operator:v1.3.0@sha256:62574f12486bb40c901cf5ed484cca264405ce5810196d86555cbb27cce1ba48
image: ghcr.io/cozystack/cozystack/cozystack-operator:v1.3.1@sha256:6cf2c5eb432193b5fa8ab202cbc56c8cd7971436639ceefa5be99aede74b3061
platformSourceUrl: 'oci://ghcr.io/cozystack/cozystack/cozystack-packages'
platformSourceRef: 'digest=sha256:a0b9ef938446b3132d3d22ad2262beb1027c48c9037b6c2346fdc2f19acd3036'
platformSourceRef: 'digest=sha256:7b0d95638195317f0d84f33e0cbd77542229c9df42de41ee22e977987043631f'
# Generic variant configuration (only used when cozystackOperator.variant=generic)
cozystack:
# Kubernetes API server host (IP only, no protocol/port)

View file

@ -5,7 +5,7 @@ sourceRef:
path: /
migrations:
enabled: false
image: ghcr.io/cozystack/cozystack/platform-migrations:v1.3.0@sha256:555e4b76421361805a84bc9088b01b23a9c4a9430bd8ebd2db82ef9677d7008c
image: ghcr.io/cozystack/cozystack/platform-migrations:v1.3.1@sha256:555e4b76421361805a84bc9088b01b23a9c4a9430bd8ebd2db82ef9677d7008c
targetVersion: 39
# Bundle deployment configuration
bundles:

View file

@ -1,2 +1,2 @@
e2e:
image: ghcr.io/cozystack/cozystack/e2e-sandbox:v1.3.0@sha256:0367a03b981df2a3ea13f411d4cb7869c2bf2c89c07d3d5c8971b9a28921ccef
image: ghcr.io/cozystack/cozystack/e2e-sandbox:v1.3.1@sha256:0367a03b981df2a3ea13f411d4cb7869c2bf2c89c07d3d5c8971b9a28921ccef

View file

@ -1 +1 @@
ghcr.io/cozystack/cozystack/matchbox:v1.3.0@sha256:85b8e04bf6f0690612dd63e80475df269f4a436d16680f8a40f2860cf16e2f74
ghcr.io/cozystack/cozystack/matchbox:v1.3.1@sha256:f61425c515e7c8b49d634cef4f050faeeaa8c455ffbbe77eee669a04dc1ed305

View file

@ -5,3 +5,6 @@ include ../../../hack/package.mk
generate:
cozyvalues-gen -v values.yaml -s values.schema.json -r README.md
../../../hack/update-crd.sh
test:
helm unittest .

View file

@ -1,39 +0,0 @@
{{- $shouldUpdateCerts := true }}
{{- $configMap := lookup "v1" "ConfigMap" .Release.Namespace "etcd-deployed-version" }}
{{- if $configMap }}
{{- $deployedVersion := index $configMap "data" "version" }}
{{- if $deployedVersion | semverCompare ">= 2.6.1" }}
{{- $shouldUpdateCerts = false }}
{{- end }}
{{- end }}
{{- if $shouldUpdateCerts }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: etcd-hook
annotations:
helm.sh/hook: post-upgrade
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
template:
metadata:
labels:
policy.cozystack.io/allow-to-apiserver: "true"
spec:
serviceAccountName: etcd-hook
containers:
- name: kubectl
image: docker.io/alpine/k8s:1.33.4
command:
- sh
args:
- -exc
- |-
kubectl --namespace={{ .Release.Namespace }} delete secrets etcd-ca-tls etcd-peer-ca-tls
sleep 10
kubectl --namespace={{ .Release.Namespace }} delete secrets etcd-client-tls etcd-peer-tls etcd-server-tls
kubectl --namespace={{ .Release.Namespace }} delete pods --selector=app.kubernetes.io/instance=etcd,app.kubernetes.io/managed-by=etcd-operator,app.kubernetes.io/name=etcd,cozystack.io/service=etcd
restartPolicy: Never
{{- end }}

View file

@ -1,26 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
annotations:
helm.sh/hook: post-upgrade
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
name: etcd-hook
rules:
- apiGroups:
- ""
resources:
- secrets
- pods
verbs:
- get
- list
- watch
- delete
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- list
- watch

View file

@ -1,15 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: etcd-hook
annotations:
helm.sh/hook: post-upgrade
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: etcd-hook
subjects:
- kind: ServiceAccount
name: etcd-hook
namespace: {{ .Release.Namespace | quote }}

View file

@ -1,7 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: etcd-hook
annotations:
helm.sh/hook: post-upgrade
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded

View file

@ -1,6 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: etcd-deployed-version
data:
version: {{ .Chart.Version }}

View file

@ -0,0 +1,34 @@
suite: etcd chart does not ship a destructive post-upgrade cert-regeneration hook
release:
name: etcd
namespace: tenant-root
templates:
- templates/check-release-name.yaml
- templates/dashboard-resourcemap.yaml
- templates/datastore.yaml
- templates/etcd-defrag.yaml
- templates/hook/job.yaml
- templates/podscrape.yaml
- templates/prometheus-rules.yaml
- templates/version.yaml
tests:
- it: renders no Job named etcd-hook
documentSelector:
path: metadata.name
value: etcd-hook
skipEmptyTemplates: true
asserts:
- hasDocuments:
count: 0
- it: renders no ConfigMap named etcd-deployed-version
documentSelector:
path: metadata.name
value: etcd-deployed-version
skipEmptyTemplates: true
asserts:
- hasDocuments:
count: 0

View file

@ -1 +1 @@
ghcr.io/cozystack/cozystack/objectstorage-sidecar:v1.3.0@sha256:5bcccbdb13979a16cee535eb5fbcdf0d87973689010a10b7b25e55c5be3edaa6
ghcr.io/cozystack/cozystack/objectstorage-sidecar:v1.3.1@sha256:3895676b15cdf186b03f231dcfdfe40812ecd76ab00c3b4e2952daccbacd57f3

View file

@ -1,5 +1,5 @@
backupController:
image: "ghcr.io/cozystack/cozystack/backup-controller:v1.3.0@sha256:e1a083dc92f26dfef004f47c1cd20a6357174aad835004f58e751c494b76649a"
image: "ghcr.io/cozystack/cozystack/backup-controller:v1.3.1@sha256:b0ed6db5cf738b774de62a2af51c9800c23dd6e977e95728d3b0098d18c8927e"
replicas: 2
debug: false
metrics:

View file

@ -10,17 +10,3 @@ subjects:
- kind: ServiceAccount
name: backupstrategy-controller
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: backups.cozystack.io:strategy-controller:velero-configmaps
namespace: cozy-velero
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: backups.cozystack.io:strategy-controller:velero-configmaps
subjects:
- kind: ServiceAccount
name: backupstrategy-controller
namespace: {{ .Release.Namespace }}

View file

@ -27,7 +27,9 @@ rules:
resources: ["pods"]
verbs: ["get", "list", "watch"]
# ConfigMaps: controller-runtime cache requires cluster-scoped list/watch;
# create/update/delete is scoped to cozy-velero via the Role below.
# create/update/delete is scoped to cozy-velero via a Role shipped by the
# velero chart (packages/system/velero/templates/backupstrategy-controller-rbac.yaml)
# so it is only created when velero is installed.
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
@ -78,14 +80,3 @@ rules:
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
# To create ResourceModifiers in ConfigMaps for Restore in Velero install namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: backups.cozystack.io:strategy-controller:velero-configmaps
namespace: cozy-velero
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["create", "get", "list", "delete", "deletecollection", "patch", "update"]

View file

@ -1,5 +1,5 @@
backupStrategyController:
image: "ghcr.io/cozystack/cozystack/backupstrategy-controller:v1.3.0@sha256:be0a9ec1f4307064b16388a24628aee46e06252738338add80b99ea1e04e62bf"
image: "ghcr.io/cozystack/cozystack/backupstrategy-controller:v1.3.1@sha256:8b0500a68c4dd027b6499d5309ccebddc56bb29f070722538cc306827e71da93"
replicas: 2
debug: false
metrics:

View file

@ -1 +1 @@
ghcr.io/cozystack/cozystack/s3manager:v0.5.0@sha256:fb65e734bbdbdaef2238769cc2ecfb54dbddaf1e0952ad438a9b3e26b9dbb4b5
ghcr.io/cozystack/cozystack/s3manager:v0.5.0@sha256:7473f0acd04d2e14393b4fc3638fe9e0d3dc15a5778b05dac9dfe1a1957d2647

View file

@ -1,3 +1,3 @@
cozystackAPI:
image: ghcr.io/cozystack/cozystack/cozystack-api:v1.3.0@sha256:5fa8648821cf1e9e08cf7c2899c4b1c4226bb74c6773327141456c7d3f2b9b7e
image: ghcr.io/cozystack/cozystack/cozystack-api:v1.3.1@sha256:9494995677a4f6f6afcf4c222d1372efea33a69515fc5de4069ff5583ec4638f
replicas: 2

View file

@ -1,4 +1,4 @@
cozystackController:
image: ghcr.io/cozystack/cozystack/cozystack-controller:v1.3.0@sha256:d03d19b78c4c98f970ac549a68b01ef6bd1ad755f5e0dcb9e08503511cdf2fdc
image: ghcr.io/cozystack/cozystack/cozystack-controller:v1.3.1@sha256:4b13d34b01e3e778feb2098053a3cccfe3260806af0b595fd7a6e27c0ff96faf
debug: false
disableTelemetry: false

View file

@ -1,6 +1,6 @@
{{- $brandingConfig := .Values._cluster.branding | default dict }}
{{- $tenantText := "v1.3.0" }}
{{- $tenantText := "v1.3.1" }}
{{- $footerText := "Cozystack" }}
{{- $titleText := "Cozystack Dashboard" }}
{{- $logoText := "" }}

View file

@ -1,6 +1,6 @@
openapiUI:
image: ghcr.io/cozystack/cozystack/openapi-ui:v1.3.0@sha256:0fa79c373a62840a617ff1ca1b0e31931c13a6cf7b0bb0ff0dc191f047a465a3
image: ghcr.io/cozystack/cozystack/openapi-ui:v1.3.1@sha256:33de92b549ddf1e56a62d79c46aeb077fbdc67ec16cf2b556be1714f5616788f
openapiUIK8sBff:
image: ghcr.io/cozystack/cozystack/openapi-ui-k8s-bff:v1.3.0@sha256:1b3ea6d4c7dbbe6a8def3b2807fffdfab2ac4afc39d7a846e57dd491fa168f92
image: ghcr.io/cozystack/cozystack/openapi-ui-k8s-bff:v1.3.1@sha256:c938fee904acd948800d4dc5e121c4c5cd64cb4a3160fb8d2f9dbff0e5168740
tokenProxy:
image: ghcr.io/cozystack/cozystack/token-proxy:v1.3.0@sha256:2e280991e07853ea48f97b0a42946afffa10d03d6a83d41099ed83e6ffc94fdc
image: ghcr.io/cozystack/cozystack/token-proxy:v1.3.1@sha256:2e280991e07853ea48f97b0a42946afffa10d03d6a83d41099ed83e6ffc94fdc

View file

@ -1 +1 @@
ghcr.io/cozystack/cozystack/grafana-dashboards:v1.3.0@sha256:2c9aa0b48e2bf6167db198f4d15882bfe51700108edf2e9f6d0942940a2c1204
ghcr.io/cozystack/cozystack/grafana-dashboards:v1.3.1@sha256:2c9aa0b48e2bf6167db198f4d15882bfe51700108edf2e9f6d0942940a2c1204

View file

@ -3,14 +3,37 @@ kamaji:
deploy: false
image:
pullPolicy: IfNotPresent
tag: v1.3.0@sha256:8c9af083b60600c0800eb56a2cda75f26007b7272a1cf019140de003bfce1a4d
tag: v1.3.1@sha256:8c727e4eb9500f7dd1c76b6daf7bb5e6cfee1984dfb79e0a8fc4c4b467937f86
repository: ghcr.io/cozystack/cozystack/kamaji
resources:
limits:
cpu: 200m
memory: 500Mi
memory: 512Mi
requests:
cpu: 100m
memory: 100Mi
memory: 256Mi
startupProbe:
httpGet:
path: /healthz
port: healthcheck
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 12
livenessProbe:
httpGet:
path: /healthz
port: healthcheck
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 1
readinessProbe:
httpGet:
path: /readyz
port: healthcheck
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 1
extraArgs:
- --migrate-image=ghcr.io/cozystack/cozystack/kamaji:v1.3.0@sha256:8c9af083b60600c0800eb56a2cda75f26007b7272a1cf019140de003bfce1a4d
- --migrate-image=ghcr.io/cozystack/cozystack/kamaji:v1.3.1@sha256:8c727e4eb9500f7dd1c76b6daf7bb5e6cfee1984dfb79e0a8fc4c4b467937f86

View file

@ -1,4 +1,4 @@
portSecurity: true
routes: ""
image: ghcr.io/cozystack/cozystack/kubeovn-plunger:v1.3.0@sha256:b75a0facb99c3b0fe8090414b3425f5c4858fff632d1de122ce9944c4daa89c0
image: ghcr.io/cozystack/cozystack/kubeovn-plunger:v1.3.1@sha256:7587822422b571e575bf1809a7537ec637f95ba67c92ffac0a14509793651f89
ovnCentralName: ovn-central

View file

@ -1,3 +1,3 @@
portSecurity: true
routes: ""
image: ghcr.io/cozystack/cozystack/kubeovn-webhook:v1.3.0@sha256:e18f9fd679e38f65362a8d0042f25468272f6d081136ad47027168d8e7e07a4a
image: ghcr.io/cozystack/cozystack/kubeovn-webhook:v1.3.1@sha256:e6334c29d3aaf0dea766c88e3e05b53ad623d1bb497b3c836e6f76adade45b29

View file

@ -1,3 +1,3 @@
storageClass: replicated
csiDriver:
image: ghcr.io/cozystack/cozystack/kubevirt-csi-driver:0.0.0@sha256:72154a97054e16cdf3dea6129d962b8d7e86b55cf9386095e8ac2ce7c8b69172
image: ghcr.io/cozystack/cozystack/kubevirt-csi-driver:0.0.0@sha256:0e70f4391b344c71a9567e1ed22ca24910116a619cb681381059b8c39f382219

View file

@ -1,5 +1,5 @@
lineageControllerWebhook:
image: ghcr.io/cozystack/cozystack/lineage-controller-webhook:v1.3.0@sha256:e8984709686a5eaf19b89da378d7b8c688ea5607e0783a88d9c9e4ccfca96fb0
image: ghcr.io/cozystack/cozystack/lineage-controller-webhook:v1.3.1@sha256:b73c3debd7b414e6c18b4816789030ef16c097076d3474f73b6d9fe1d7dadf98
debug: false
localK8sAPIEndpoint:
enabled: true

View file

@ -12,18 +12,13 @@ image: image-linstor-gui
image-linstor-gui:
docker buildx build images/linstor-gui \
--provenance false \
--builder=$(BUILDER) \
--platform=linux/amd64,linux/arm64 \
--build-arg LINSTOR_GUI_VERSION=$(LINSTOR_GUI_VERSION) \
--tag $(REGISTRY)/linstor-gui:$(call settag,$(LINSTOR_GUI_VERSION)) \
--tag $(REGISTRY)/linstor-gui:$(call settag,$(LINSTOR_GUI_VERSION)-$(TAG)) \
--cache-from type=registry,ref=$(REGISTRY)/linstor-gui:latest \
--cache-to type=inline \
--metadata-file images/linstor-gui.json \
--push=$(PUSH) \
--label "org.opencontainers.image.source=https://github.com/cozystack/cozystack" \
--load=$(LOAD)
$(BUILDX_ARGS)
REPOSITORY="$(REGISTRY)/linstor-gui" \
yq -i '.image.repository = strenv(REPOSITORY)' values.yaml
TAG="$(call settag,$(LINSTOR_GUI_VERSION))@$$(yq e '."containerimage.digest"' images/linstor-gui.json -o json -r)" \

View file

@ -3,12 +3,10 @@
## @param image.tag LINSTOR GUI container image tag (digest recommended)
image:
repository: ghcr.io/cozystack/cozystack/linstor-gui
tag: 2.3.0
tag: 2.3.0@sha256:554a47730231f43449fde91e9f8b9c01a94ec637b46aaa55d814b0cbb8d92680
## @section Deployment
## @param replicas Number of linstor-gui replicas
replicas: 1
## @section LINSTOR controller connection
## @param linstor.endpoint In-cluster URL of the LINSTOR controller REST API (HTTPS, mTLS)
## @param linstor.clientSecret Kubernetes Secret with `tls.crt`, `tls.key`, `ca.crt` used as the mTLS client certificate against the LINSTOR controller. Created by the `linstor` package.

View file

@ -5,7 +5,7 @@ include ../../../hack/common-envs.mk
include ../../../hack/package.mk
LINSTOR_VERSION ?= 1.33.2
LINSTOR_CSI_VERSION ?= v1.10.5
LINSTOR_CSI_VERSION ?= v1.10.6
image: image-piraeus-server image-linstor-csi

View file

@ -1,6 +1,6 @@
FROM golang:1.25 AS builder
ARG VERSION=v1.10.5
ARG VERSION=v1.10.6
ARG LINSTOR_WAIT_UNTIL_VERSION=v0.3.1
ARG TARGETARCH
ARG TARGETOS

View file

@ -1,25 +1,7 @@
diff --git a/pkg/client/linstor.go b/pkg/client/linstor.go
index f544493..98e7fde 100644
index ac4651d..46d565d 100644
--- a/pkg/client/linstor.go
+++ b/pkg/client/linstor.go
@@ -181,7 +181,7 @@ func LogLevel(s string) func(*Linstor) error {
func (s *Linstor) ListAllWithStatus(ctx context.Context) ([]volume.VolumeStatus, error) {
var vols []volume.VolumeStatus
- resourcesByName := make(map[string][]lapi.Resource)
+ resourcesByName := make(map[string][]lapi.ResourceWithVolumes)
resDefs, err := s.client.ResourceDefinitions.GetAll(ctx, lapi.RDGetAllRequest{WithVolumeDefinitions: true})
if err != nil {
@@ -194,7 +194,7 @@ func (s *Linstor) ListAllWithStatus(ctx context.Context) ([]volume.VolumeStatus,
}
for i := range allResources {
- resourcesByName[allResources[i].Name] = append(resourcesByName[allResources[i].Name], allResources[i].Resource)
+ resourcesByName[allResources[i].Name] = append(resourcesByName[allResources[i].Name], allResources[i])
}
for _, rd := range resDefs {
@@ -462,6 +462,14 @@ func (s *Linstor) Clone(ctx context.Context, vol, src *volume.Info, params *volu
return err
}
@ -35,7 +17,7 @@ index f544493..98e7fde 100644
logger.Debug("reconcile extra properties")
err = s.client.ResourceDefinitions.Modify(ctx, vol.ID, lapi.GenericPropsModify{OverrideProps: vol.Properties})
@@ -1280,6 +1288,14 @@ func (s *Linstor) VolFromSnap(ctx context.Context, snap *volume.Snapshot, vol *v
@@ -1297,6 +1305,14 @@ func (s *Linstor) VolFromSnap(ctx context.Context, snap *volume.Snapshot, vol *v
return err
}
@ -50,7 +32,7 @@ index f544493..98e7fde 100644
logger.Debug("reconcile extra properties")
err = s.client.ResourceDefinitions.Modify(ctx, vol.ID, lapi.GenericPropsModify{OverrideProps: vol.Properties})
@@ -1470,9 +1486,8 @@ func (s *Linstor) reconcileSnapshotResources(ctx context.Context, snapshot *volu
@@ -1487,9 +1503,8 @@ func (s *Linstor) reconcileSnapshotResources(ctx context.Context, snapshot *volu
return fmt.Errorf("snapshot '%s' not deployed on any node", snap.Name)
}
@ -62,7 +44,7 @@ index f544493..98e7fde 100644
for _, snapNode := range snap.Nodes {
if err := s.NodeAvailable(ctx, snapNode); err != nil {
logger.WithField("selected node candidate", snapNode).WithError(err).Debug("node is not available")
@@ -1480,13 +1495,23 @@ func (s *Linstor) reconcileSnapshotResources(ctx context.Context, snapshot *volu
@@ -1497,13 +1512,23 @@ func (s *Linstor) reconcileSnapshotResources(ctx context.Context, snapshot *volu
}
if slices.Contains(preferredNodes, snapNode) {
@ -92,7 +74,7 @@ index f544493..98e7fde 100644
}
if selectedNode == "" {
@@ -1679,6 +1704,114 @@ func (s *Linstor) reconcileResourcePlacement(ctx context.Context, vol *volume.In
@@ -1696,6 +1721,114 @@ func (s *Linstor) reconcileResourcePlacement(ctx context.Context, vol *volume.In
return nil
}
@ -207,40 +189,8 @@ index f544493..98e7fde 100644
// FindSnapsByID searches the snapshot in the backend
func (s *Linstor) FindSnapsByID(ctx context.Context, id string) ([]*volume.Snapshot, error) {
snapshotId, err := volume.ParseSnapshotId(id)
@@ -2173,7 +2306,7 @@ func (s *Linstor) Status(ctx context.Context, volId string) ([]string, *csi.Volu
"volume": volId,
}).Debug("getting assignments")
- ress, err := s.client.Resources.GetAll(ctx, volId)
+ ress, err := s.client.Resources.GetResourceView(ctx, &lapi.ListOpts{Resource: []string{volId}})
if err != nil {
return nil, nil, fmt.Errorf("failed to list resources for '%s': %w", volId, err)
}
@@ -2261,13 +2394,20 @@ func GetSnapshotRemoteAndReadiness(snap *lapi.Snapshot) (string, bool, error) {
return "", slices.Contains(snap.Flags, lapiconsts.FlagSuccessful), nil
}
-func NodesAndConditionFromResources(ress []lapi.Resource) ([]string, *csi.VolumeCondition) {
+func NodesAndConditionFromResources(ress []lapi.ResourceWithVolumes) ([]string, *csi.VolumeCondition) {
var allNodes, abnormalNodes []string
for i := range ress {
res := &ress[i]
- allNodes = append(allNodes, res.NodeName)
+ // A resource is a CSI publish target if any of its volumes were created
+ // by ControllerPublishVolume, identified by the temporary-diskless-attach property.
+ if slices.ContainsFunc(res.Volumes, func(v lapi.Volume) bool {
+ createdFor, ok := v.Props[linstor.PropertyCreatedFor]
+ return ok && createdFor == linstor.CreatedForTemporaryDisklessAttach
+ }) {
+ allNodes = append(allNodes, res.NodeName)
+ }
if res.State == nil {
abnormalNodes = append(abnormalNodes, res.NodeName)
diff --git a/pkg/volume/parameter.go b/pkg/volume/parameter.go
index 39acd95..aed18ab 100644
index 39acd95..54d1dfb 100644
--- a/pkg/volume/parameter.go
+++ b/pkg/volume/parameter.go
@@ -50,6 +50,7 @@ const (

View file

@ -0,0 +1,132 @@
diff --git a/pkg/client/linstor.go b/pkg/client/linstor.go
index ac4651d..9d75c79 100644
--- a/pkg/client/linstor.go
+++ b/pkg/client/linstor.go
@@ -653,11 +653,35 @@ func (s *Linstor) Attach(ctx context.Context, volId, node string, rwxBlock bool)
}
if otherResInUse > 0 && rwxBlock {
- rdPropsModify := lapi.GenericPropsModify{OverrideProps: map[string]string{
+ rdProps := map[string]string{
linstor.PropertyAllowTwoPrimaries: "yes",
- }}
+ }
- err = s.client.ResourceDefinitions.Modify(ctx, volId, rdPropsModify)
+ // DRBD requires Protocol C whenever allow-two-primaries is enabled.
+ // If the resource is configured with Protocol A or B (typically
+ // through its resource-group), drbdadm adjust on the satellites
+ // fails with "Protocol C required" (errno 139) and live migration
+ // cannot proceed. Override Protocol to C on the resource-definition
+ // itself so it applies to every connection (including diskless
+ // peers, e.g. a TieBreaker). The marker lets Detach revert exactly
+ // the override we installed without disturbing operator-set props.
+ proto, protoErr := s.getEffectiveDrbdProtocol(ctx, volId)
+ if protoErr != nil {
+ s.log.WithError(protoErr).WithField("volume", volId).
+ Warn("failed to determine effective DRBD protocol; skipping Protocol=C override for dual-attach")
+ } else if proto == "A" || proto == "B" {
+ s.log.WithFields(logrus.Fields{
+ "volume": volId,
+ "protocol": proto,
+ }).Info("installing Protocol=C override on resource-definition for dual-attach")
+
+ rdProps[linstor.PropertyDrbdNetProtocol] = "C"
+ rdProps[linstor.PropertyCsiProtocolOverride] = "yes"
+ }
+
+ err = s.client.ResourceDefinitions.Modify(ctx, volId, lapi.GenericPropsModify{
+ OverrideProps: rdProps,
+ })
if err != nil {
return "", err
}
@@ -774,11 +798,26 @@ func (s *Linstor) Detach(ctx context.Context, volId, node string) error {
}
if resInUse == 1 {
- rdPropsModify := lapi.GenericPropsModify{DeleteProps: []string{
- linstor.PropertyAllowTwoPrimaries,
- }}
+ deleteProps := []string{linstor.PropertyAllowTwoPrimaries}
+
+ // Drop the Protocol=C override only if Attach installed it (gated by
+ // our marker), so an operator-set Protocol property on the
+ // resource-definition is preserved.
+ rd, getErr := s.client.ResourceDefinitions.Get(ctx, volId)
+ if getErr != nil {
+ log.WithError(getErr).Warn("failed to fetch resource-definition props; skipping Protocol=C override removal")
+ } else if rd.Props[linstor.PropertyCsiProtocolOverride] != "" {
+ log.Info("removing Protocol=C override from resource-definition")
- err = s.client.ResourceDefinitions.Modify(ctx, volId, rdPropsModify)
+ deleteProps = append(deleteProps,
+ linstor.PropertyDrbdNetProtocol,
+ linstor.PropertyCsiProtocolOverride,
+ )
+ }
+
+ err = s.client.ResourceDefinitions.Modify(ctx, volId, lapi.GenericPropsModify{
+ DeleteProps: deleteProps,
+ })
if err != nil {
return nil404(err)
}
@@ -805,6 +844,33 @@ func (s *Linstor) Detach(ctx context.Context, volId, node string) error {
return nil404(s.client.Resources.Delete(ctx, volId, node))
}
+// getEffectiveDrbdProtocol returns the DRBD network protocol ("A", "B" or "C")
+// that LINSTOR will hand to drbdadm for the given resource. Resource-definition
+// properties take precedence; otherwise the value is inherited from the
+// resource-group. An empty string is returned when neither level sets the
+// property (LINSTOR's compiled-in default is "C").
+func (s *Linstor) getEffectiveDrbdProtocol(ctx context.Context, volId string) (string, error) {
+ rd, err := s.client.ResourceDefinitions.Get(ctx, volId)
+ if err != nil {
+ return "", err
+ }
+
+ if v, ok := rd.Props[linstor.PropertyDrbdNetProtocol]; ok {
+ return v, nil
+ }
+
+ if rd.ResourceGroupName == "" {
+ return "", nil
+ }
+
+ rg, err := s.client.ResourceGroups.Get(ctx, rd.ResourceGroupName)
+ if err != nil {
+ return "", err
+ }
+
+ return rg.Props[linstor.PropertyDrbdNetProtocol], nil
+}
+
// CapacityBytes returns the amount of free space in the storage pool specified by the params and topology.
func (s *Linstor) CapacityBytes(ctx context.Context, storagePools []string, overProvision *float64, segments map[string]string) (int64, error) {
log := s.log.WithField("storage-pools", storagePools).WithField("segments", segments)
diff --git a/pkg/linstor/const.go b/pkg/linstor/const.go
index 9a5f79c..c8bc9c3 100644
--- a/pkg/linstor/const.go
+++ b/pkg/linstor/const.go
@@ -44,6 +44,19 @@ const (
// PropertyAllowTwoPrimaries is DRBD option to allow second primary. Mainly used for live-migration.
PropertyAllowTwoPrimaries = lc.NamespcDrbdNetOptions + "/allow-two-primaries"
+ // PropertyDrbdNetProtocol is the DRBD network replication protocol option
+ // (A = async, B = semi-sync, C = sync). DRBD requires Protocol C whenever
+ // allow-two-primaries is enabled, otherwise drbdadm adjust fails with
+ // "Protocol C required" (errno 139).
+ PropertyDrbdNetProtocol = lc.NamespcDrbdNetOptions + "/protocol"
+
+ // PropertyCsiProtocolOverride marks a resource-connection where this driver
+ // has installed a temporary Protocol=C override to make a Protocol-A/B
+ // resource compatible with allow-two-primaries during live migration. The
+ // marker lets us safely remove only the overrides we set, without touching
+ // connection-level overrides installed by the operator.
+ PropertyCsiProtocolOverride = lc.NamespcAuxiliary + "/csi-protocol-override"
+
// CreatedForTemporaryDisklessAttach marks a resource as temporary, i.e. it should be removed after it is no longer
// needed.
CreatedForTemporaryDisklessAttach = "temporary-diskless-attach"

View file

@ -13,4 +13,4 @@ linstor:
linstorCSI:
image:
repository: ghcr.io/cozystack/cozystack/linstor-csi
tag: v1.10.5@sha256:b8f59b5659fb1791cb764d3f37df4cf29920aadcc10637231ba7d857233f377d
tag: v1.10.6@sha256:aef5c6a8351a86daba781390c123f39f947da7ade59075b59f89fa8e3edeb75a

View file

@ -155,7 +155,7 @@ spec:
serviceAccountName: multus
containers:
- name: kube-multus
image: ghcr.io/cozystack/cozystack/multus-cni:v1.3.0@sha256:6735ffc12e5e660951f5a42943b38c308f33774a55836e94c191001405b58ec0
image: ghcr.io/cozystack/cozystack/multus-cni:v1.3.1@sha256:6735ffc12e5e660951f5a42943b38c308f33774a55836e94c191001405b58ec0
command: [ "/usr/src/multus-cni/bin/multus-daemon" ]
resources:
requests:
@ -201,7 +201,7 @@ spec:
fieldPath: spec.nodeName
initContainers:
- name: install-multus-binary
image: ghcr.io/cozystack/cozystack/multus-cni:v1.3.0@sha256:6735ffc12e5e660951f5a42943b38c308f33774a55836e94c191001405b58ec0
image: ghcr.io/cozystack/cozystack/multus-cni:v1.3.1@sha256:6735ffc12e5e660951f5a42943b38c308f33774a55836e94c191001405b58ec0
command:
- "/usr/src/multus-cni/bin/install_multus"
- "-d"

View file

@ -1,3 +1,3 @@
objectstorage:
controller:
image: "ghcr.io/cozystack/cozystack/objectstorage-controller:v1.3.0@sha256:7a9e4bf9c3f95f364756396815bb51b6c0f58f85db653460574d94b96cd3c7d5"
image: "ghcr.io/cozystack/cozystack/objectstorage-controller:v1.3.1@sha256:7a9e4bf9c3f95f364756396815bb51b6c0f58f85db653460574d94b96cd3c7d5"

View file

@ -177,7 +177,7 @@ seaweedfs:
bucketClassName: "seaweedfs"
region: ""
sidecar:
image: "ghcr.io/cozystack/cozystack/objectstorage-sidecar:v1.3.0@sha256:5bcccbdb13979a16cee535eb5fbcdf0d87973689010a10b7b25e55c5be3edaa6"
image: "ghcr.io/cozystack/cozystack/objectstorage-sidecar:v1.3.1@sha256:3895676b15cdf186b03f231dcfdfe40812ecd76ab00c3b4e2952daccbacd57f3"
certificates:
commonName: "SeaweedFS CA"
ipAddresses: []

View file

@ -0,0 +1,26 @@
# Grants the backupstrategy-controller permission to manage ResourceModifier
# ConfigMaps in the Velero install namespace. Lives here (not in the
# backupstrategy-controller chart) so that the Role is only created when
# velero is actually installed — otherwise the chart would try to create it
# in a namespace that does not exist.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: backups.cozystack.io:strategy-controller:velero-configmaps
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["create", "get", "list", "delete", "deletecollection", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: backups.cozystack.io:strategy-controller:velero-configmaps
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: backups.cozystack.io:strategy-controller:velero-configmaps
subjects:
- kind: ServiceAccount
name: backupstrategy-controller
namespace: cozy-backup-controller

View file

@ -17,6 +17,8 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
@ -24,6 +26,7 @@ import (
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
@ -123,8 +126,18 @@ func (r *REST) Get(
return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
// Check if user has access to this namespace
hasAccess, err := r.hasAccessToNamespace(ctx, name)
if err != nil {
return nil, err
}
if !hasAccess {
// Return Forbidden to follow standard K8s RBAC behavior
return nil, apierrors.NewForbidden(r.gvr.GroupResource(), name, fmt.Errorf("access denied"))
}
ns := &corev1.Namespace{}
err := r.c.Get(ctx, types.NamespacedName{Namespace: "", Name: name}, ns, &client.GetOptions{Raw: opts})
err = r.c.Get(ctx, types.NamespacedName{Namespace: "", Name: name}, ns, &client.GetOptions{Raw: opts})
if err != nil {
return nil, err
}
@ -143,11 +156,33 @@ func (r *REST) Get(
// -----------------------------------------------------------------------------
func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch.Interface, error) {
// Extract user identity once for the lifetime of the watch — it does not
// change between events and rebuilding it per event is wasteful.
u, ok := request.UserFrom(ctx)
if !ok {
return nil, apierrors.NewUnauthorized("user missing in context")
}
username := u.GetName()
groups := make(map[string]struct{})
for _, group := range u.GetGroups() {
groups[group] = struct{}{}
}
nsList := &corev1.NamespaceList{}
nsWatch, err := r.w.Watch(ctx, nsList, &client.ListOptions{Raw: &metav1.ListOptions{
// Build upstream watch options with field and label selectors
rawOpts := &metav1.ListOptions{
Watch: true,
ResourceVersion: opts.ResourceVersion,
}})
}
if opts.FieldSelector != nil {
rawOpts.FieldSelector = opts.FieldSelector.String()
}
if opts.LabelSelector != nil {
rawOpts.LabelSelector = opts.LabelSelector.String()
}
nsWatch, err := r.w.Watch(ctx, nsList, &client.ListOptions{Raw: rawOpts})
if err != nil {
return nil, err
}
@ -189,6 +224,30 @@ func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch
continue
}
// Apply defensive filtering for field and label selectors
if opts.FieldSelector != nil {
if !opts.FieldSelector.Matches(fields.Set{"metadata.name": ns.Name}) {
continue
}
}
if opts.LabelSelector != nil {
if !opts.LabelSelector.Matches(labels.Set(ns.Labels)) {
continue
}
}
// Check if user has access to this namespace using the cached
// identity — avoids re-extracting user/groups on every event.
hasAccess, err := r.hasAccessToNamespaceForUser(ctx, ns.Name, username, groups)
if err != nil {
klog.ErrorS(err, "Failed to check access for namespace in watch", "namespace", ns.Name)
continue
}
if !hasAccess {
// User doesn't have access, skip this event
continue
}
out := &corev1alpha1.TenantNamespace{
TypeMeta: metav1.TypeMeta{
APIVersion: corev1alpha1.SchemeGroupVersion.String(),
@ -309,6 +368,26 @@ func (r *REST) makeList(src *corev1.NamespaceList, allowed []string) *corev1alph
return out
}
// matchesSubject checks if a RoleBinding subject matches the user's identity.
// It handles Group, User, and ServiceAccount subjects with proper namespace fallback.
func matchesSubject(subj rbacv1.Subject, bindingNamespace, username string, groups map[string]struct{}) bool {
switch subj.Kind {
case "Group":
_, ok := groups[subj.Name]
return ok
case "User":
return subj.Name == username
case "ServiceAccount":
saNamespace := subj.Namespace
if saNamespace == "" {
saNamespace = bindingNamespace
}
return username == fmt.Sprintf("system:serviceaccount:%s:%s", saNamespace, subj.Name)
default:
return false
}
}
func (r *REST) filterAccessible(
ctx context.Context,
names []string,
@ -347,22 +426,9 @@ func (r *REST) filterAccessible(
subjectLoop:
for j := range rbs.Items[i].Subjects {
subj := rbs.Items[i].Subjects[j]
switch subj.Kind {
case "Group":
if _, ok = groups[subj.Name]; ok {
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
break subjectLoop
}
case "User":
if subj.Name == u.GetName() {
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
break subjectLoop
}
case "ServiceAccount":
if u.GetName() == fmt.Sprintf("system:serviceaccount:%s:%s", subj.Namespace, subj.Name) {
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
break subjectLoop
}
if matchesSubject(subj, rbs.Items[i].Namespace, u.GetName(), groups) {
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
break subjectLoop
}
}
}
@ -373,6 +439,60 @@ func (r *REST) filterAccessible(
return allowed, nil
}
// hasAccessToNamespace checks if the user has access to a single namespace.
// This is optimized for Get/Watch operations where we check one namespace at a time.
// It lists RoleBindings only in the target namespace instead of all cluster RoleBindings.
func (r *REST) hasAccessToNamespace(
ctx context.Context,
namespace string,
) (bool, error) {
u, ok := request.UserFrom(ctx)
if !ok {
return false, fmt.Errorf("user missing in context")
}
groups := make(map[string]struct{})
for _, group := range u.GetGroups() {
groups[group] = struct{}{}
}
return r.hasAccessToNamespaceForUser(ctx, namespace, u.GetName(), groups)
}
// hasAccessToNamespaceForUser is the inner check that does not re-extract user
// identity from context. Use this in hot paths (e.g. the Watch loop) where the
// caller has already cached the user name and groups.
func (r *REST) hasAccessToNamespaceForUser(
ctx context.Context,
namespace, username string,
groups map[string]struct{},
) (bool, error) {
// Check privileged groups
if _, ok := groups["system:masters"]; ok {
return true, nil
}
if _, ok := groups["cozystack-cluster-admin"]; ok {
return true, nil
}
// List RoleBindings only in the target namespace
rbs := &rbacv1.RoleBindingList{}
err := r.c.List(ctx, rbs, client.InNamespace(namespace))
if err != nil {
return false, fmt.Errorf("failed to list rolebindings in %s: %w", namespace, err)
}
// Check if user is in any RoleBinding subjects
for i := range rbs.Items {
for j := range rbs.Items[i].Subjects {
subj := rbs.Items[i].Subjects[j]
if matchesSubject(subj, rbs.Items[i].Namespace, username, groups) {
return true, nil
}
}
}
return false, nil
}
// -----------------------------------------------------------------------------
// Boiler-plate
// -----------------------------------------------------------------------------

View file

@ -3,10 +3,20 @@
package tenantnamespace
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/endpoints/request"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
)
func TestMakeListSortsAlphabetically(t *testing.T) {
@ -38,3 +48,466 @@ func TestMakeListSortsAlphabetically(t *testing.T) {
}
}
}
// Security tests for IDOR fix
func TestHasAccessToNamespace_WithUserAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected user to have access, but got false")
}
}
func TestHasAccessToNamespace_WithoutAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
// RoleBinding for different user
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasAccess {
t.Error("expected user to NOT have access, but got true")
}
}
func TestHasAccessToNamespace_WithGroupAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "Group", Name: "test-group", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated", "test-group"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected user to have access via group, but got false")
}
}
func TestHasAccessToNamespace_SystemMasters(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "admin",
Groups: []string{"system:masters"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected system:masters to have access, but got false")
}
}
func TestHasAccessToNamespace_CozyAdminGroup(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "cozy-admin",
Groups: []string{"cozystack-cluster-admin"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected cozystack-cluster-admin to have access, but got false")
}
}
func TestHasAccessToNamespace_ServiceAccount(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "ServiceAccount", Name: "test-sa", Namespace: "tenant-test"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "system:serviceaccount:tenant-test:test-sa",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected service account to have access, but got false")
}
}
func TestHasAccessToNamespace_ServiceAccountEmptyNamespace(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
// ServiceAccount subject with empty namespace should default to RoleBinding namespace
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "ServiceAccount", Name: "test-sa", Namespace: ""}, // Empty namespace
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "system:serviceaccount:tenant-test:test-sa",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected service account with empty namespace to have access, but got false")
}
}
func TestGet_WithAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)
obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tn, ok := obj.(*corev1alpha1.TenantNamespace)
if !ok {
t.Fatalf("expected *TenantNamespace, got %T", obj)
}
if tn.Name != "tenant-test" {
t.Errorf("expected name %q, got %q", "tenant-test", tn.Name)
}
if tn.Kind != "TenantNamespace" {
t.Errorf("expected Kind=TenantNamespace, got %q", tn.Kind)
}
if tn.APIVersion != corev1alpha1.SchemeGroupVersion.String() {
t.Errorf("expected APIVersion=%q, got %q", corev1alpha1.SchemeGroupVersion.String(), tn.APIVersion)
}
}
func TestGet_WithoutAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}
// RoleBinding for different user
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)
obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if obj != nil {
t.Errorf("expected nil object, got %v", obj)
}
// Verify it returns Forbidden to follow standard K8s RBAC behavior
if !apierrors.IsForbidden(err) {
t.Errorf("expected Forbidden error, got %v", err)
}
}
func TestGet_NonTenantNamespace(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)
client := fake.NewClientBuilder().
WithScheme(scheme).
Build()
r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}
u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:masters"},
}
ctx := request.WithUser(context.Background(), u)
obj, err := r.Get(ctx, "default", &metav1.GetOptions{})
if err == nil {
t.Fatal("expected error for non-tenant namespace, got nil")
}
if obj != nil {
t.Errorf("expected nil object, got %v", obj)
}
if !apierrors.IsNotFound(err) {
t.Errorf("expected NotFound error, got %v", err)
}
}