This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Ecosystem

All entries on this page were added by people who worked on these and thus self-identified as being part of the Project Capsule Ecosystem.

Integrations

Capsule works well with other CNCF kubernetes based solutions. Below you can see the ones we have documented. In the end it can work with any solution, due to Capsule’s kubernetes native approach:

Addons

Addons are separate projects which interact with the core Capsule Project. Since our commitment is, to have a stable core API we decided to push towards an addon based ecosystem. If you have a new addon, which interacts with the capsule core project, consider adding the addon.

Proxy

core ux

Enhance the user experience by allowing users to query the Kubernetes API and only getting the results, they are supposed to get.

Rancher

community ux

Integrate Capsule with Rancher to manage Capsule Tenants and their resources with Rancher Projects.

ArgoCD

vendor gitops

This addon is designed for kubernetes administrators, to automatically translate their existing Capsule Tenants into Argo Appprojects.

Sops Operator

core secrets gitops

Handle SOPS Secrets in a multi-tenant and kubernetes-native way.

FluxCD

core gitops

In particular enables Tenants to manage their resources, including creating Namespaces.

Cortex Proxy

core observability

Route metrics to cortex organizations based on the relational of namespace metrics to capsule tenants.

1 - Integrations

Integrate Capsule with other platforms and solutions

1.1 - ArgoCD

Capsule Integration with ArgoCD

Integration

Resource Actions

You may provide Custom Resource Actions for Capsule specific resources and interactions.

Namespace Resource Actions

Namespace Resource Actions

With the following configuration, ArgoCD will show Cordon and Resume actions for the Namespace resource. The Cordon action will set the projectcapsule.dev/cordoned label to true, while the Resume action will set it to false. This is only for Namespaces part of a Capsule Tenant.

resource.customizations.actions.Namespace: |
  mergeBuiltinActions: true
  discovery.lua: |
    actions = {
      cordon = {
        iconClass = "fa fa-solid fa-pause",
        disabled = true,
      },
      uncordon = {
        iconClass = "fa fa-solid fa-play",
        disabled = true,
      },
    }

    local function has_managed_ownerref()
      if obj.metadata == nil or obj.metadata.ownerReferences == nil then
        return false
      end

      for _, ref in ipairs(obj.metadata.ownerReferences) do
        if ref.kind == "Tenant" and ref.apiVersion == "capsule.clastix.io/v1beta2" then
          return true
        end
      end

      return false
    end
    if not has_managed_ownerref() then
      return {}
    end
    local labels = {}
    if obj.metadata ~= nil and obj.metadata.labels ~= nil then
      labels = obj.metadata.labels
    end

    local cordoned = labels["projectcapsule.dev/cordoned"] == "true"

    if cordoned then
      actions["uncordon"].disabled = false
    else
      actions["cordon"].disabled = false
    end

    return actions

  definitions:
    - name: cordon
      action.lua: |
        if obj.metadata == nil then
          obj.metadata = {}
        end
        if obj.metadata.labels == nil then
          obj.metadata.labels = {}
        end

        obj.metadata.labels["projectcapsule.dev/cordoned"] = "true"
        return obj

    - name: uncordon
      action.lua: |
        if obj.metadata ~= nil and obj.metadata.labels ~= nil then
          obj.metadata.labels["projectcapsule.dev/cordoned"] = "false"
        end
        return obj  

Tenant Resource Actions

Tenant Resource Actions

With the following configuration, ArgoCD will show Cordon and Resume actions for the Tenant resource. The Cordon action will set the spec.cordon field to true, while the Resume action will set it to false.

resource.customizations.actions.capsule.clastix.io_Tenant: |
  mergeBuiltinActions: true
  discovery.lua: |
    actions = {}
    actions["cordon"] = {
      ["iconClass"] = "fa fa-solid fa-pause",
      ["disabled"] = true,
    }
    actions["uncordon"] = {
      ["iconClass"] = "fa fa-solid fa-play",
      ["disabled"] = true,
    }

    local suspend = false
    if obj.spec ~= nil and obj.spec.cordoned ~= nil then
      suspend = obj.spec.cordoned
    end

    if suspend then
      actions["uncordon"]["disabled"] = false
    else
      actions["cordon"]["disabled"] = false
    end

    return actions

  definitions:
    - name: cordon
      action.lua: |
        if obj.spec == nil then
          obj.spec = {}
        end
        obj.spec.cordoned = true
        return obj

    - name: uncordon
      action.lua: |
        if obj.spec ~= nil and obj.spec.cordoned ~= nil and obj.spec.cordoned then
          obj.spec.cordoned = false
        end
        return obj  

TenantResource and GlobalTenantResource Actions

With the following configuration, ArgoCD will show Cordon, Uncordon, and Reconcile actions for TenantResource and GlobalTenantResource. Cordoning pauses all apply and delete operations. The Reconcile action triggers an immediate reconcile by setting the reconcile.projectcapsule.dev/requestedAt annotation. Both Cordon and Reconcile are disabled when the resource is already cordoned, since the controller skips processing in that state.

resource.customizations.actions.capsule.clastix.io_TenantResource: |
  mergeBuiltinActions: true
  discovery.lua: |
    local actions = {}
    actions["cordon"] = {
      ["iconClass"] = "fa fa-fw fa-pause",
      ["disabled"] = true,
    }
    actions["uncordon"] = {
      ["iconClass"] = "fa fa-fw fa-play",
      ["disabled"] = true,
    }
    actions["reconcile"] = {
      ["iconClass"] = "fa fa-fw fa-rotate-right",
      ["disabled"] = true,
    }

    local cordoned = false
    if obj.spec ~= nil and obj.spec.cordoned ~= nil then
      cordoned = obj.spec.cordoned
    end

    if cordoned then
      actions["uncordon"]["disabled"] = false
    else
      actions["cordon"]["disabled"] = false
      actions["reconcile"]["disabled"] = false
    end

    return actions

  definitions:
    - name: cordon
      action.lua: |
        if obj.spec == nil then
          obj.spec = {}
        end
        obj.spec.cordoned = true
        return obj

    - name: uncordon
      action.lua: |
        if obj.spec ~= nil and obj.spec.cordoned ~= nil and obj.spec.cordoned then
          obj.spec.cordoned = false
        end
        return obj

    - name: reconcile
      action.lua: |
        local os = require("os")
        if obj.metadata.annotations == nil then
          obj.metadata.annotations = {}
        end
        obj.metadata.annotations["reconcile.projectcapsule.dev/requestedAt"] = os.date("!%Y-%m-%dT%XZ")
        return obj  

Apply the same block for GlobalTenantResource by replacing the resource key:

resource.customizations.actions.capsule.clastix.io_GlobalTenantResource: |
  # same content as above  

Resource Health

You may provide Custom Resource Health for Capsule specific resources and interactions.

Tenant Resource Health

Tenant Resource Actions

Shows Suspended when the Tenant is cordoned, reflecting that no new workloads can be scheduled in its Namespaces. Reports Degraded when the Ready condition is False, Healthy when Ready is True, and Progressing otherwise.

resource.customizations.health.capsule.clastix.io_Tenant: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Cordoned" and condition.status == "True" then
      hs.status = "Suspended"
      hs.message = condition.message
      return hs
    end
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

Namespace Resource Health

Namespace Resource Actions

Suspends a Namespace when it’s Cordoned. This is only for Namespaces part of a Capsule Tenant.

resource.customizations.health.Namespace: |
  local hs = {}
  local function has_managed_ownerref()
    if obj.metadata == nil or obj.metadata.ownerReferences == nil then
      return false
    end

    for _, ref in ipairs(obj.metadata.ownerReferences) do
      if ref.kind == "Tenant" and ref.apiVersion == "capsule.clastix.io/v1beta2" then
        return true
      end
    end

    return false
  end

  local labels = {}
  if obj.metadata ~= nil and obj.metadata.labels ~= nil then
    labels = obj.metadata.labels
  end

  local cordoned = labels["projectcapsule.dev/cordoned"] == "true"

  if cordoned and has_managed_ownerref() then
    hs.status = "Suspended"
    hs.message = "Namespace is cordoned (tenant-managed)"
    return hs
  end

  if obj.status ~= nil and obj.status.phase ~= nil then
    if obj.status.phase == "Active" then
      hs.status = "Healthy"
      hs.message = "Namespace is Active"
      return hs
    else
      hs.status = "Progressing"
      hs.message = "Namespace phase is " .. obj.status.phase
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Namespace status"
  return hs  

CapsuleConfiguration Resource Health

Reports health based on the Ready condition.

resource.customizations.health.capsule.clastix.io_CapsuleConfiguration: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

TenantOwner Resource Health

Reports Degraded when the TenantOwner failed to reconcile, and Healthy when the owner has been successfully bound to its tenant.

resource.customizations.health.capsule.clastix.io_TenantOwner: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

ResourcePool Resource Health

Reports Degraded when any resource is exhausted or not ready, and Healthy when the pool is active and within limits.

resource.customizations.health.capsule.clastix.io_ResourcePool: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  if obj.status.exhaustions ~= nil then
    local exhausted = {}
    for resource, _ in pairs(obj.status.exhaustions) do
      table.insert(exhausted, resource)
    end
    table.sort(exhausted)
    if #exhausted > 0 then
      hs.status = "Degraded"
      hs.message = "Pool exhausted for: " .. table.concat(exhausted, ", ")
      return hs
    end
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

ResourcePoolClaim Resource Health

Reports Suspended when unbound (waiting for a pool), Degraded when not ready, and Healthy when bound and ready.

resource.customizations.health.capsule.clastix.io_ResourcePoolClaim: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Bound" and condition.status == "False" then
      hs.status = "Suspended"
      hs.message = condition.message
      return hs
    end
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

CustomQuota Resource Health

Reports Degraded when the quota reconciliation failed (e.g. a matched resource has a missing field), and Healthy when usage has been successfully calculated for the namespace.

resource.customizations.health.capsule.clastix.io_CustomQuota: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

GlobalCustomQuota Resource Health

Reports Degraded when the quota reconciliation failed, and Healthy when usage has been successfully calculated across all selected namespaces.

resource.customizations.health.capsule.clastix.io_GlobalCustomQuota: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

GlobalResourceQuota Resource Health

Reports Progressing when initialization or reconciliation is in progress (e.g. Ready condition reason Reconciling), Degraded when the resource quota reconciliation failed, and Healthy when usage has been successfully calculated across all selected namespaces.

resource.customizations.health.capsule.clastix.io_GlobalResourceQuota: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" then
      if condition.status == "True" then
        hs.status = "Healthy"
        hs.message = condition.message
        return hs
      elseif condition.status == "False" then
        if condition.reason == "Reconciling" then
          hs.status = "Progressing"
          hs.message = condition.message
          return hs
        end
        hs.status = "Degraded"
        hs.message = condition.message
        return hs
      else
        hs.status = "Progressing"
        hs.message = condition.message
        return hs
      end
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

TenantResource Resource Health

Reports Suspended when the replication is cordoned (paused for maintenance). Reports Degraded when the replication of tenant-scoped resources failed, and Healthy when all resources have been successfully replicated into the target namespaces.

resource.customizations.health.capsule.clastix.io_TenantResource: |
  local hs = {}
  if obj.spec ~= nil and obj.spec.cordoned ~= nil and obj.spec.cordoned then
    hs.status = "Suspended"
    hs.message = "Replication is cordoned"
    return hs
  end

  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

GlobalTenantResource Resource Health

Reports Suspended when the replication is cordoned (paused for maintenance). Reports Degraded when the cluster-wide resource replication failed, and Healthy when all resources have been successfully replicated across all tenant namespaces.

resource.customizations.health.capsule.clastix.io_GlobalTenantResource: |
  local hs = {}
  if obj.spec ~= nil and obj.spec.cordoned ~= nil and obj.spec.cordoned then
    hs.status = "Suspended"
    hs.message = "Replication is cordoned"
    return hs
  end

  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

Capsule Proxy

The following health checks apply to Capsule Proxy CRDs.

ProxySetting Resource Health

Reports Degraded when a per-user or per-group ProxySetting failed to reconcile, and Healthy when the proxy rules have been successfully applied.

resource.customizations.health.capsule.clastix.io_ProxySetting: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

GlobalProxySettings Resource Health

Reports Degraded when the cluster-wide proxy settings failed to reconcile, and Healthy when the global proxy rules have been successfully applied.

resource.customizations.health.capsule.clastix.io_GlobalProxySettings: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" and condition.status == "False" then
      hs.status = "Degraded"
      hs.message = condition.message
      return hs
    end
    if condition.type == "Ready" and condition.status == "True" then
      hs.status = "Healthy"
      hs.message = condition.message
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

SOPS Operator

The following health checks apply to SOPS Operator CRDs (addons.projectcapsule.dev), which is a Capsule Addon.

SopsSecret Resource Health

Reports Degraded when decryption or secret replication failed, and Healthy when all managed secrets have been successfully decrypted and replicated.

resource.customizations.health.addons.projectcapsule.dev_SopsSecret: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" then
      if condition.status == "True" then
        hs.status = "Healthy"
        hs.message = condition.message
        return hs
      end
      if condition.status == "False" then
        hs.status = "Degraded"
        hs.message = condition.message
        return hs
      end
      if condition.status == "Unknown" then
        hs.status = "Progressing"
        hs.message = condition.message
        return hs
      end
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

SopsProvider Resource Health

Reports Degraded when one or more decryption providers failed to load or validate, and Healthy when all configured providers are available.

resource.customizations.health.addons.projectcapsule.dev_SopsProvider: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" then
      if condition.status == "True" then
        hs.status = "Healthy"
        hs.message = condition.message
        return hs
      end
      if condition.status == "False" then
        hs.status = "Degraded"
        hs.message = condition.message
        return hs
      end
      if condition.status == "Unknown" then
        hs.status = "Progressing"
        hs.message = condition.message
        return hs
      end
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

GlobalSopsSecret Resource Health

Reports Degraded when decryption or cross-namespace secret replication failed, and Healthy when all managed secrets have been successfully decrypted and replicated into their target namespaces.

resource.customizations.health.addons.projectcapsule.dev_GlobalSopsSecret: |
  local hs = {}
  if obj.status == nil or obj.status.conditions == nil then
    hs.status = "Progressing"
    hs.message = "Waiting for status"
    return hs
  end

  if obj.metadata ~= nil and obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
      and obj.status.observedGeneration ~= obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "Waiting for reconciliation (generation mismatch)"
    return hs
  end

  for _, condition in ipairs(obj.status.conditions) do
    if condition.type == "Ready" then
      if condition.status == "True" then
        hs.status = "Healthy"
        hs.message = condition.message
        return hs
      end
      if condition.status == "False" then
        hs.status = "Degraded"
        hs.message = condition.message
        return hs
      end
      if condition.status == "Unknown" then
        hs.status = "Progressing"
        hs.message = condition.message
        return hs
      end
    end
  end

  hs.status = "Progressing"
  hs.message = "Waiting for Ready condition"
  return hs  

1.2 - Crossplane

Capsule Integration with Crossplane

1.3 - Dashboard

Capsule Integration with Kubernetes Dashboard

This guide works with the kubernetes dashboard v2.0.0 (Chart 6.0.8). It has not yet been tested successfully with with v3.x version of the dashboard.

We recommend to use Headlamp as a more modern alternative to the Kubernetes Dashboard.

This guide describes how to integrate the Kubernetes Dashboard and Capsule Proxy with OIDC authorization.

OIDC Authentication

Your cluster must also be configured to use OIDC Authentication for seamless Kubernetes RBAC integration. In a such scenario, you should have in the kube-apiserver.yaml manifest the following content:

spec:
  containers:
  - command:
    - kube-apiserver
    ...
    - --oidc-issuer-url=https://${OIDC_ISSUER}
    - --oidc-ca-file=/etc/kubernetes/oidc/ca.crt
    - --oidc-client-id=${OIDC_CLIENT_ID}
    - --oidc-username-claim=preferred_username
    - --oidc-groups-claim=groups
    - --oidc-username-prefix=-

Where ${OIDC_CLIENT_ID} refers to the client ID that all tokens must be issued.

For this client we need: 1. Check Valid Redirect URIs: in the oauth2-proxy configuration we set redirect-url: “https://${DASHBOARD_URL}/oauth2/callback”, it needs to add this path to the Valid Redirect URIs 2. Create a mapper with Mapper Type ‘Group Membership’ and Token Claim Name ‘groups’. 3. Create a mapper with Mapper Type ‘Audience’ and Included Client Audience and Included Custom Audience set to your client name (${OIDC_CLIENT_ID}).

OAuth2 Proxy

To enable the proxy authorization from the Kubernetes dashboard to Keycloak, we need to use an OAuth proxy. In this article, we will use oauth2-proxy and install it as a pod in the Kubernetes Dashboard namespace. Alternatively, we can install oauth2-proxy in a different namespace or use it as a sidecar container in the Kubernetes Dashboard deployment.

Prepare the values for oauth2-proxy:

cat > values-oauth2-proxy.yaml <<EOF
config:
  clientID: "${OIDC_CLIENT_ID}"
  clientSecret: ${OIDC_CLIENT_SECRET}

extraArgs:
  provider: "keycloak-oidc"
  redirect-url: "https://${DASHBOARD_URL}/oauth2/callback"
  oidc-issuer-url: "https://${KEYCLOAK_URL}/auth/realms/${OIDC_CLIENT_ID}"
  pass-access-token: true
  set-authorization-header: true
  pass-user-headers: true

ingress:
  enabled: true
  path: "/oauth2"
  hosts:
    - ${DASHBOARD_URL}
  tls:
    - hosts:
      - ${DASHBOARD_URL}
EOF

More information about the keycloak-oidc provider can be found on the oauth2-proxy documentation. We’re ready to install the oauth2-proxy:

helm repo add oauth2-proxy https://oauth2-proxy.github.io/manifests
helm install oauth2-proxy oauth2-proxy/oauth2-proxy -n ${KUBERNETES_DASHBOARD_NAMESPACE} -f values-oauth2-proxy.yaml

Configuring Keycloak

The Kubernetes cluster must be configured with a valid OIDC provider: for our guide, we’re giving for granted that Keycloak is used, if you need more info please follow the OIDC Authentication section.

In a such scenario, you should have in the kube-apiserver.yaml manifest the following content:

spec:
  containers:
  - command:
    - kube-apiserver
    ...
    - --oidc-issuer-url=https://${OIDC_ISSUER}
    - --oidc-ca-file=/etc/kubernetes/oidc/ca.crt
    - --oidc-client-id=${OIDC_CLIENT_ID}
    - --oidc-username-claim=preferred_username
    - --oidc-groups-claim=groups
    - --oidc-username-prefix=-

Where ${OIDC_CLIENT_ID} refers to the client ID that all tokens must be issued.

For this client we need:

  1. Check Valid Redirect URIs: in the oauth2-proxy configuration we set redirect-url: "https://${DASHBOARD_URL}/oauth2/callback", it needs to add this path to the Valid Redirect URIs
  2. Create a mapper with Mapper Type ‘Group Membership’ and Token Claim Name ‘groups’.
  3. Create a mapper with Mapper Type ‘Audience’ and Included Client Audience and Included Custom Audience set to your client name(OIDC_CLIENT_ID).

Configuring Kubernetes Dashboard

If your Capsule Proxy uses HTTPS and the CA certificate is not the Kubernetes CA, you need to add a secret with the CA for the Capsule Proxy URL.

cat > ca.crt<< EOF
-----BEGIN CERTIFICATE-----
...
...
...
-----END CERTIFICATE-----
EOF

kubectl create secret generic certificate --from-file=ca.crt=ca.crt -n ${KUBERNETES_DASHBOARD_NAMESPACE}

Prepare the values for the Kubernetes Dashboard:

cat > values-kubernetes-dashboard.yaml <<EOF
extraVolumes:
  - name: token-ca
    projected:
      sources:
        - serviceAccountToken:
            expirationSeconds: 86400
            path: token
        - secret:
            name: certificate
            items:
              - key: ca.crt
                path: ca.crt
extraVolumeMounts:
  - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
    name: token-ca

ingress:
  enabled: true
  annotations:
    nginx.ingress.kubernetes.io/auth-signin: https://${DASHBOARD_URL}/oauth2/start?rd=$escaped_request_uri
    nginx.ingress.kubernetes.io/auth-url: https://${DASHBOARD_URL}/oauth2/auth
    nginx.ingress.kubernetes.io/auth-response-headers: "authorization"
  hosts:
    - ${DASHBOARD_URL}
  tls:
    - hosts:
      - ${DASHBOARD_URL}

extraEnv:
  - name: KUBERNETES_SERVICE_HOST
    value: '${CAPSULE_PROXY_URL}'
  - name: KUBERNETES_SERVICE_PORT
    value: '${CAPSULE_PROXY_PORT}'
EOF

To add the Certificate Authority for the Capsule Proxy URL, we use the volume token-ca to mount the ca.crt file. Additionally, we set the environment variables KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT to route requests to the Capsule Proxy.

Now you can install the Kubernetes Dashboard:

helm repo add kubernetes-dashboard https://kubernetes.github.io/dashboard/
helm install kubernetes-dashboard kubernetes-dashboard/kubernetes-dashboard -n ${KUBERNETES_DASHBOARD_NAMESPACE} -f values-kubernetes-dashboard.yaml

1.4 - Envoy-Gateway

Capsule Integration with Envoy (Gateway API)

There’s different ways to use Gateway API in a multi-tenant setup. This guide suggested a strong isolated implementation using the Envoy Gateway Project. The Architecture suggested looks something like this:

Namespace Resource Actions

Each tenant will get it’s own -system Namespace. However that namespace is not managed by the Tenant nor part of it. It’s the namespace where the platform deploys managed services for each Tenant, which are out of bound for TenantOwners.

Example

Implementation of the above architecture looks something like this.

Managed Gateway

With this GlobalTenantResource we generate the managed gateway for each tenant. The EnvoyProxy is the custom resource used by the Envoy Gateway project to manage the lifecycle of the Envoy instances. The Gateway is the standard resource defined by the Gateway API, which references the EnvoyProxy as infrastructure and defines the listeners and allowed routes.

---
apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: managed-envoy-gateway
spec:
  scope: Tenant
  resyncPeriod: 30s
  resources:
    - context:
        resources:
          - apiVersion: gateway.networking.k8s.io/v1
            kind: HTTPRoute
            index: https
            selector:
              matchLabels:
                projectcapsule.dev/tenant: "{{tenant.name}}"
      generators:
        - missingKey: zero
          template: |
            {{- $ingressBandwidth := dig "spec" "data" "networking" "ingress" "bandwidth" "" $.tenant }}
            {{- $egressBandwidth := dig "spec" "data" "networking" "egress" "bandwidth" "" $.tenant }}
            {{- $loadBalancerIP := dig "spec" "data" "networking" "ingress" "loadbalancer" "" $.tenant }}
            ---
            apiVersion: gateway.envoyproxy.io/v1alpha1
            kind: EnvoyProxy
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-gateway
              namespace: tenant-{{ $.tenant.metadata.name }}-system
            spec:
              logging:
                level:
                  default: info
              provider:
                type: Kubernetes
                kubernetes:
                  envoyDeployment:
                    replicas: 2
                    pod:
                      priorityClassName: tenant-critical
                      {{- if or $ingressBandwidth $egressBandwidth }}
                      annotations:
                        {{- with $ingressBandwidth }}
                        kubernetes.io/ingress-bandwidth: {{ . }}
                        {{- end }}
                        {{- with $egressBandwidth }}
                        kubernetes.io/egress-bandwidth: {{ . }}
                        {{- end }}
                      {{- end }}
                  {{- with $loadBalancerIP }}
                  envoyService:
                    loadBalancerIP: {{ . }}
                  {{- end }}            

        - missingKey: zero
          template: |
            ---
            apiVersion: gateway.networking.k8s.io/v1
            kind: Gateway
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-gateway
              namespace: tenant-{{ $.tenant.metadata.name }}-system
              annotations:
                cert-manager.io/cluster-issuer: managed-cluster-issuer
                cert-manager.io/private-key-size: "4096"
                cert-manager.io/private-key-algorithm: RSA
            spec:
              gatewayClassName: tenants
              infrastructure:
                parametersRef:
                  group: gateway.envoyproxy.io
                  kind: EnvoyProxy
                  name: tenant-{{ $.tenant.metadata.name }}-gateway
              listeners:
                - name: http-challenge
                  port: 80
                  protocol: HTTP
                  allowedRoutes:
                    namespaces:
                      from: Selector
                      selector:
                        matchLabels:
                          capsule.clastix.io/tenant: "{{ $.tenant.metadata.name }}"
                {{- range $_, $http := $.https }}
                  {{- range $i, $hostname := $http.spec.hostnames }}
                - name: {{ $http.metadata.namespace }}-{{ $http.metadata.name }}-{{ $i }}
                  port: 443
                  protocol: HTTPS
                  hostname: {{ $hostname}}
                  tls:
                    mode: Terminate
                    certificateRefs:
                      - group: ''
                        kind: Secret
                        name: {{ $http.metadata.namespace }}-{{ $http.metadata.name }}-{{ $i }}-tls
                  allowedRoutes:
                    namespaces:
                      from: Selector
                      selector:
                        matchLabels:
                           kubernetes.io/metadata.name: "{{ $http.metadata.namespace }}"
                  {{- end }}
                {{- end }}            

Certificate Management

If we additionally would like to do Certificate Management via cert-manager in combination with ACME HTTP-01 challenges we probably want to provide the users with a ClusterIssuer per Tenant:

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: tenant-acme-issuer
spec:
  scope: Tenant
  resources:
    - rawItems:
      - apiVersion: cert-manager.io/v1
        kind: Issuer
        metadata:
          name: {{tenant.name}}-acme-http
          namespace: {{tenant.name}}-system
        spec:
          acme:
            email: platform@email.com
            server: https://acme-staging-v02.api.letsencrypt.org/directory
            privateKeySecretRef:
              name: cert-letsencrypt-staging
            solvers:
              - http01:
                  gatewayHTTPRoute:
                    parentRefs:
                      - group: gateway.networking.k8s.io
                        kind: Gateway
                        name: {{tenant.name}}-gateway
                        namespace: {{tenant.name}}-system
                        sectionName: http-challenge

1.5 - External Secrets Operator

Integrate shared and per-Tenant secret stores, generate passwords, and distribute credentials with Capsule.

External Secrets Operator (ESO) fetches credentials from a secret backend or generates them, then writes Kubernetes Secrets. Capsule can provision the ESO resources and distribute the resulting Secrets across a Tenant’s namespaces.

This guide covers three patterns:

PatternHow it works
Shared ClusterSecretStoreApproved Tenants read credentials intended to be shared, using one backend identity.
Dedicated ClusterSecretStore per TenantCapsule generates a store restricted to that Tenant’s namespaces, with a separate backend identity.
Generated password per TenantESO creates one source Secret, then Capsule replicates its value into every namespace of that Tenant.

A namespaced SecretStore is usable in its own namespace. A ClusterSecretStore is cluster-scoped and can be referenced across namespaces, subject to its conditions. Neither resource contains the application credentials itself: an ExternalSecret references the store and describes the target Secret. See ESO’s multi-tenancy guide.

Prerequisites

Run the platform setup as a cluster administrator. You need:

  • Capsule with GlobalTenantResource generators and scope: Tenant support.
  • ESO installed with the external-secrets.io/v1 APIs and the generators.external-secrets.io/v1alpha1 Password API. Its controllers must watch the platform and Tenant namespaces and process ClusterSecretStore resources.
  • kubectl, and Python 3 for the password verification example.
  • For the store examples, a reachable Vault server with a KV v2 engine mounted at secret, and permission to provision its policies and tokens. Replace https://vault.example.com with your endpoint and configure a trusted CA if needed.

The store examples use Vault to demonstrate backend permissions. The same namespace restrictions apply to other ESO providers, including Azure Key Vault. The password-generation example does not require an external backend or a store.

Example Tenants and namespaces

Save this as tenants.yaml and apply it as a cluster administrator. If these Tenants already exist, add the example label to their current manifests while preserving their owners and other configuration.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
  labels:
    secrets.example.com/enabled: "true"
spec:
  owners:
    - kind: Group
      name: solar-engineers
---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: wind
  labels:
    secrets.example.com/enabled: "true"
spec:
  owners:
    - kind: Group
      name: wind-engineers
---
apiVersion: v1
kind: Namespace
metadata:
  name: solar-dev
  labels:
    capsule.clastix.io/tenant: solar
---
apiVersion: v1
kind: Namespace
metadata:
  name: solar-prod
  labels:
    capsule.clastix.io/tenant: solar
---
apiVersion: v1
kind: Namespace
metadata:
  name: wind-dev
  labels:
    capsule.clastix.io/tenant: wind
kubectl apply -f tenants.yaml
kubectl get namespaces -L capsule.clastix.io/tenant

The secrets.example.com/enabled label selects Tenants for the GlobalTenantResources below. The capsule.clastix.io/tenant namespace label identifies their ownership and is protected by Capsule. Tenant owners normally create namespaces through Capsule’s namespace workflow.

Platform namespace and replication permissions

Keep backend authentication credentials and generated source Secrets in a platform-owned namespace, outside any Tenant. Tenant owners must not have access to this namespace or permission to modify cluster-scoped stores and GlobalTenantResources.

Save this as eso-provisioner.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: capsule-secrets-system
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: capsule-eso
  namespace: capsule-secrets-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: capsule-eso-generators
  namespace: capsule-secrets-system
rules:
  - apiGroups: ["external-secrets.io"]
    resources: ["externalsecrets"]
    verbs: ["get", "list", "create", "patch", "delete"]
  - apiGroups: ["generators.external-secrets.io"]
    resources: ["passwords"]
    verbs: ["get", "list", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: capsule-eso-generators
  namespace: capsule-secrets-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: capsule-eso-generators
subjects:
  - kind: ServiceAccount
    name: capsule-eso
    namespace: capsule-secrets-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: capsule-eso-replication
rules:
  - apiGroups: ["external-secrets.io"]
    resources: ["clustersecretstores"]
    verbs: ["get", "list", "create", "patch", "delete"]
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: capsule-eso-replication
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: capsule-eso-replication
subjects:
  - kind: ServiceAccount
    name: capsule-eso
    namespace: capsule-secrets-system
kubectl apply -f eso-provisioner.yaml

The GlobalTenantResources use this ServiceAccount through Capsule impersonation. The ClusterRole permits Secret replication into current and future Tenant namespaces, which requires broad Secret access; reserve this identity for the platform. ESO uses its own permissions to read authentication credentials, invoke generators, and write source Secrets.

Secure ClusterSecretStores

A store needs two independent access boundaries:

  • Kubernetes namespace access: spec.conditions determines which namespaces may reference the store.
  • Backend access: the store’s Vault policy, cloud IAM role, or equivalent determines which remote secrets it may fetch.

All users of a store share its backend permissions. A name such as tenant-solar-vault, or a tenant-specific remoteRef.key in an example, does not restrict users to that path. Someone able to create an ExternalSecret can request a different key within the backend identity’s permissions.

For an existing platform-only store, keep its provider configuration and restrict its conditions to the platform namespace:

spec:
  conditions:
    - namespaces:
        - capsule-secrets-system

Alternatively, a namespace selector using matchExpressions with key capsule.clastix.io/tenant and operator DoesNotExist can admit all namespaces outside Capsule Tenants. An explicit namespace allowlist is narrower. With no conditions, a ClusterSecretStore is usable from all namespaces. Conditions are alternatives: matching any entry grants access, so remove broader entries when restricting an existing store. See ClusterSecretStore conditions.

Use a shared ClusterSecretStore

Use a shared store for credentials that all participating Tenants are allowed to read, such as access to a common package registry. This example admits namespaces belonging to solar and wind.

Provision a Vault identity with a policy allowing only the shared KV v2 path. For example, save this as eso-shared.hcl:

path "secret/data/shared/*" {
  capabilities = ["read"]
}

The policy uses the Vault KV v2 read path, which includes the data segment. An ESO remoteRef.key below is relative to the secret mount and omits data.

Using an authenticated Vault CLI and jq, create the policy and store its token directly in Kubernetes:

set -o pipefail
vault policy write eso-shared eso-shared.hcl
vault token create -policy=eso-shared -format=json \
  | jq -j '.auth.client_token' \
  | kubectl -n capsule-secrets-system create secret generic vault-shared \
      --from-file=token=/dev/stdin

Manage the token’s expiration, renewal, and rotation as part of your backend setup. ESO also supports Vault Kubernetes authentication when you want to use dedicated ServiceAccount identities instead.

Save this as shared-store.yaml:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: shared-vault
spec:
  conditions:
    - namespaceSelector:
        matchExpressions:
          - key: capsule.clastix.io/tenant
            operator: In
            values: ["solar", "wind"]
  provider:
    vault:
      server: https://vault.example.com
      path: secret
      version: v2
      auth:
        tokenSecretRef:
          name: vault-shared
          namespace: capsule-secrets-system
          key: token
kubectl apply -f shared-store.yaml
kubectl wait --for=condition=Ready --timeout=120s clustersecretstore/shared-vault

Create a Vault record at secret/shared/registry with a password property using your normal secret-management workflow. A user in an admitted namespace can then apply shared-credential.yaml:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: shared-registry
  namespace: solar-dev
spec:
  refreshPolicy: Periodic
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: shared-vault
  target:
    name: shared-registry
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: shared/registry
        property: password
kubectl apply -f shared-credential.yaml
kubectl -n solar-dev wait --for=condition=Ready --timeout=120s externalsecret/shared-registry

ESO creates the shared-registry Secret in solar-dev. Apply the same ExternalSecret in another admitted namespace to fetch that shared credential there as well. A namespace outside solar and wind cannot use this store, even if its users know the store name.

Create a dedicated ClusterSecretStore per Tenant

For tenant-specific credentials, give each store a different backend identity. This example generates tenant-solar-vault and tenant-wind-vault, with names derived from the Capsule Tenant name.

First provision a separate Vault policy and token for each Tenant. Solar’s eso-tenant-solar.hcl policy is:

path "secret/data/tenants/solar/*" {
  capabilities = ["read"]
}
set -o pipefail
vault policy write eso-tenant-solar eso-tenant-solar.hcl
vault token create -policy=eso-tenant-solar -format=json \
  | jq -j '.auth.client_token' \
  | kubectl -n capsule-secrets-system create secret generic vault-tenant-solar \
      --from-file=token=/dev/stdin

Repeat for Wind, using secret/data/tenants/wind/*, policy eso-tenant-wind, and Secret vault-tenant-wind. Do not reuse a token that can read both tenants’ paths. These Kubernetes authentication Secrets hold a token key and remain in the platform namespace. Capsule generates the stores; it does not create Vault policies or tokens.

Save this as tenant-stores.yaml:

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: tenant-secret-stores
spec:
  scope: Tenant
  resyncPeriod: 60s
  serviceAccount:
    name: capsule-eso
    namespace: capsule-secrets-system
  tenantSelector:
    matchLabels:
      secrets.example.com/enabled: "true"
  resources:
    - generators:
        - missingKey: error
          template: |
            apiVersion: external-secrets.io/v1
            kind: ClusterSecretStore
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-vault
            spec:
              conditions:
                - namespaceSelector:
                    matchLabels:
                      capsule.clastix.io/tenant: {{ $.tenant.metadata.name | quote }}
              provider:
                vault:
                  server: https://vault.example.com
                  path: secret
                  version: v2
                  auth:
                    tokenSecretRef:
                      name: vault-tenant-{{ $.tenant.metadata.name }}
                      namespace: capsule-secrets-system
                      key: token            
kubectl apply -f tenant-stores.yaml
kubectl wait --for=condition=Ready --timeout=120s globaltenantresource/tenant-secret-stores
kubectl wait --for=condition=Ready --timeout=120s \
  clustersecretstore/tenant-solar-vault clustersecretstore/tenant-wind-vault

scope: Tenant creates one cluster-scoped store per Tenant. The store itself has no metadata.namespace; its authentication Secret reference must include one. Its namespace selector automatically admits new namespaces owned by that Tenant.

After creating secret/tenants/solar/application in Vault with a password property, apply tenant-credential.yaml:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: application-credentials
  namespace: solar-dev
spec:
  refreshPolicy: Periodic
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: tenant-solar-vault
  target:
    name: application-credentials
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: tenants/solar/application
        property: password
kubectl apply -f tenant-credential.yaml
kubectl -n solar-dev wait --for=condition=Ready --timeout=120s \
  externalsecret/application-credentials

Check both boundaries when validating this setup: an ExternalSecret in wind-dev referencing tenant-solar-vault should be denied by the store’s namespace conditions. An ExternalSecret in solar-dev using its own store but requesting tenants/wind/application should be denied by Vault. Use a separate test resource and target Secret when checking denied requests, and inspect its conditions with kubectl describe externalsecret.

Generate one password and distribute it to every Tenant namespace

An ESO Password generator produces a new random value each time it is invoked. Referencing the same generator from several ExternalSecrets produces different passwords. To share one credential throughout a Tenant, generate it in one source Secret and replicate that Secret. The generator resource holds the password-generation settings, not the generated password. See generator behavior.

flowchart LR
  GTR[GlobalTenantResource: scope Tenant] --> Password[Password generator]
  GTR --> ES[One ExternalSecret per Tenant]
  Password --> ES
  ES --> Source[Source Secret in capsule-secrets-system]
  Source --> Replication[GlobalTenantResource: scope Namespace]
  Replication --> Dev[Same Secret in solar-dev]
  Replication --> Prod[Same Secret in solar-prod]

Generate the source Secrets

Save this as tenant-password-sources.yaml. It creates a Password generator and an ExternalSecret for each enabled Tenant, all in capsule-secrets-system:

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: tenant-password-sources
spec:
  scope: Tenant
  resyncPeriod: 60s
  serviceAccount:
    name: capsule-eso
    namespace: capsule-secrets-system
  tenantSelector:
    matchLabels:
      secrets.example.com/enabled: "true"
  resources:
    - generators:
        - missingKey: error
          template: |
            apiVersion: generators.external-secrets.io/v1alpha1
            kind: Password
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-password
              namespace: capsule-secrets-system
            spec:
              length: 32
              digits: 6
              symbols: 0
              noUpper: false
              allowRepeat: true
            ---
            apiVersion: external-secrets.io/v1
            kind: ExternalSecret
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-password
              namespace: capsule-secrets-system
            spec:
              refreshPolicy: CreatedOnce
              target:
                name: tenant-{{ $.tenant.metadata.name }}-password
                creationPolicy: Owner
                template:
                  engineVersion: v2
                  mergePolicy: Merge
                  type: Opaque
                  metadata:
                    labels:
                      secrets.example.com/tenant: {{ $.tenant.metadata.name | quote }}
                      secrets.example.com/purpose: shared-password
              dataFrom:
                - sourceRef:
                    generatorRef:
                      apiVersion: generators.external-secrets.io/v1alpha1
                      kind: Password
                      name: tenant-{{ $.tenant.metadata.name }}-password            
kubectl apply -f tenant-password-sources.yaml
kubectl wait --for=condition=Ready --timeout=120s globaltenantresource/tenant-password-sources
kubectl -n capsule-secrets-system wait --for=condition=Ready --timeout=120s \
  externalsecret/tenant-solar-password externalsecret/tenant-wind-password

For Solar, ESO creates capsule-secrets-system/tenant-solar-password with a password data key. Wind gets an independent value in tenant-wind-password. A generator reference resolves in the ExternalSecret’s namespace and does not need secretStoreRef.

CreatedOnce avoids scheduled regeneration. Capsule’s resyncPeriod reconciles the ESO resource definitions; it does not request a new password on every pass. However, ESO can sync again if the target Secret changes or is deleted, or if the ExternalSecret is recreated. Treat source recreation as credential rotation and retain a backup if the credential must survive recovery.

The explicit target.template.metadata is significant: it defines the source Secret’s labels instead of inheriting the Capsule management labels from its ExternalSecret. Capsule skips Secrets marked projectcapsule.dev/created-by: resources during replication to prevent loops. Keep the source Secret owned by ESO and its replicas owned by Capsule; do not stamp Capsule’s reserved labels onto the source. ESO’s template metadata behavior controls this distinction.

Replicate the source into all namespaces

Save this as tenant-password-replication.yaml:

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: tenant-password-replication
spec:
  scope: Namespace
  resyncPeriod: 60s
  serviceAccount:
    name: capsule-eso
    namespace: capsule-secrets-system
  tenantSelector:
    matchLabels:
      secrets.example.com/enabled: "true"
  resources:
    - namespacedItems:
        - apiVersion: v1
          kind: Secret
          namespace: capsule-secrets-system
          name: "tenant-{{tenant.name}}-password"
          optional: false
kubectl apply -f tenant-password-replication.yaml
kubectl wait --for=condition=Ready --timeout=120s globaltenantresource/tenant-password-replication

The namespaced reference selects one source by name for each Tenant. With scope: Namespace and no namespaceSelector, Capsule copies it into every namespace of that Tenant, preserving its name and data. New namespaces receive the existing value on reconciliation. optional: false reports a missing source instead of silently producing no copies.

Capsule’s generator and replication readiness only covers Kubernetes resources. It does not prove that ESO has created the source Secret, which is why the commands wait for the source ExternalSecrets separately. If the two GlobalTenantResources are applied together, replication may initially report a missing Secret and then succeed once ESO creates it.

Verify identical values and tenant isolation

Check that both Solar namespaces have Solar’s Secret and Wind has only its own:

kubectl -n solar-dev get secret tenant-solar-password
kubectl -n solar-prod get secret tenant-solar-password
kubectl -n wind-dev get secret tenant-wind-password
kubectl -n wind-dev get secret tenant-solar-password

The last command should report NotFound. This Python check compares the values without printing passwords or hashes:

import base64
import json
import subprocess


def password(namespace, name):
    raw = subprocess.check_output(
        ["kubectl", "-n", namespace, "get", "secret", name, "-o", "json"]
    )
    return base64.b64decode(json.loads(raw)["data"]["password"])


solar = password("capsule-secrets-system", "tenant-solar-password")
wind = password("capsule-secrets-system", "tenant-wind-password")
assert len(solar) == 32
assert solar == password("solar-dev", "tenant-solar-password")
assert solar == password("solar-prod", "tenant-solar-password")
assert wind == password("wind-dev", "tenant-wind-password")
assert solar != wind
print("Passwords match within each Tenant and differ between Tenants.")

Create another namespace belonging to Solar and repeat the comparison after replication. The new namespace should receive the same source value; adding a namespace does not invoke the Password generator again.

Workloads consume the local copy through a Secret volume or secretKeyRef. For example, use this fragment in a Pod template in solar-dev or solar-prod:

env:
  - name: APPLICATION_PASSWORD
    valueFrom:
      secretKeyRef:
        name: tenant-solar-password
        key: password

Register the generated credential with the service that authenticates it. Creating or copying a Secret does not set a database user’s password or otherwise configure that service.

Rotation and lifecycle

For backend-managed values, update the backend record. The Periodic ExternalSecrets fetch it on their next refresh. Backend authentication tokens have their own lifecycle and must be renewed or replaced separately.

For generated passwords, CreatedOnce keeps the source stable during ordinary reconciliation. Recreating the source can produce a different password, and Capsule will then distribute the new value. Coordinate rotation with the authenticating service and all consuming workloads. Applications using environment variables need a restart to load changed Secret values; applications reading Secret volumes need to reload them.

The example uses creationPolicy: Owner, so deleting the source ExternalSecret also makes its source Secret eligible for garbage collection. Removing the Tenant’s opt-in label or deleting its GlobalTenantResources can prune generated objects and replicas under Capsule’s object management rules. Check that replicas are removed and service credentials are revoked during offboarding. Source removal alone is not immediate credential revocation.

For credentials that must survive deleting and recreating an ExternalSecret, ESO documents creationPolicy: Orphan with an immutable target. That changes cleanup and rotation: retained source Secrets need explicit offboarding, and immutable replicas cannot be updated in place. Choose that lifecycle deliberately rather than adding immutability to the replication example unchanged.

1.6 - Headlamp

Capsule Integration with Headlamp

Headlamp is an easy-to-use and extensible Kubernetes web UI.

Headlamp was created to blend the traditional feature set of other web UIs/dashboards (i.e., to list and view resources) with added functionality.

Prerequisites

  1. You will need a running Capsule Proxy instance.
  2. For Authentication you will need a Confidential OIDC client configured in your OIDC provider, such as Keycloak, Dex, or Google Cloud Identity. By default the Kubernetes API only validates tokens against a Public OIDC client, so you will need to configure your OIDC provider to allow the Headlamp client to issue tokens. You must make use of the Kubernetes Authentication Configuration, which allows to define multiple audiences (clients). This way we can issue tokens for a headlamp client, which is Confidential (Client Secret), and a kubernetes client, which is Public. The Kubernetes API will validate the tokens against both clients. The Config might look like this:
apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
jwt:
- issuer:
    url: https://keycloak/realms/realm-name
    audiences:
    - kubernetes
    - headlamp
    audienceMatchPolicy: MatchAny # This one is important
  claimMappings:
    username:
      claim: 'email'
      prefix: ""
    groups:
      claim: 'groups'
      prefix: ""

Read More

Integration

To install Headlamp, you can use the Helm chart provided in the Headlamp repository. It’s a bit special how Headlamp handles Certificate Authorities. We need to inject the capsule-proxy CA into the trust store for Headlamp. In the below example we are using the CA Bundle from alpine (because we also need to trust the CA of the OIDC Issuer, in this case it uses Let’s Encrypt). See the issues #3707 and #127. Essentially Golang uses some environment variables to allow specifying cert files/dirs overriding the system defaults. By Default this is under /etc/ssl/. You can change this behavior by defining the environment variables SSL_CERT_FILE or/and SSL_CERT_DIR.

It’s recommended to install headlamp in the capsule-system namespace. Otherwise you need to somehow replicate the internal ca secret to the namespace, where headlamp is deployed to. For this case Cert-Manager Trust-Bundles might be useful.

With the following values we got it to work:

config:
  inCluster: true
  extraArgs:
  - -insecure-ssl
env:
  - name: KUBERNETES_SERVICE_HOST
    value: "capsule-proxy.capsule-system.svc"
  - name: KUBERNETES_SERVICE_PORT
    value: "9001"
  - name: "OIDC_ISSUER_URL"
    value: "https://keycloak/realms/realm-name"
  - name: "OIDC_CLIENT_ID"
    value: "headlamp"
  - name: "OIDC_CLIENT_SECRET"
    value: "<SECRET>"
  - name: "OIDC_USE_ACCESS_TOKEN"
    value: "false"
  #- name: "OIDC_SCOPES"
  #  value: "openid profile email groups offline_access"
volumeMounts:
  - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
    name: token-ca
  - name: ca-store
    mountPath: /etc/ssl/
volumes:
  - name: ca-store
    emptyDir: {}
  - name: capsule-proxy
    secret:
      secretName: capsule-proxy
  - name: token-ca
    projected:
      sources:
        - serviceAccountToken:
            path: token
        - secret:
            items:
              - key: ca.crt
                path: ca.crt
            name: capsule-proxy
        - downwardAPI:
            items:
              - fieldRef:
                  apiVersion: v1
                  fieldPath: metadata.namespace
                path: namespace
initContainers:
- name: add-ca
  image: alpine:3
  command: ["/bin/sh","-c"]
  args:
  - |
    set -e
    cp -R /etc/ssl/* /work/
    cat /ca/ca.crt >> /work/certs/ca-certificates.crt    
  volumeMounts:
  - name: ca-store
    mountPath: /work
  - name: capsule-proxy
    mountPath: /ca
  securityContext:
    runAsNonRoot: true
    capabilities:
      drop:
      - ALL
    readOnlyRootFilesystem: false
    allowPrivilegeEscalation: false
    privileged: false
    runAsUser: 65534
    runAsGroup: 65534
podSecurityContext:
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault
securityContext:
  capabilities:
    drop:
    - ALL
  readOnlyRootFilesystem: false
  allowPrivilegeEscalation: false
  privileged: false
  runAsUser: 100
  runAsGroup: 101
  fsGroup: 101
  fsGroupChangePolicy: "Always"

Note: The secret capsule-proxy refers to the secret which is being used by the capsule-proxy instance directly, not the self-signed-ca secret.

Plugins

We provide a plugin for Headlamp to enhance the user experience when using Capsule. The plugin is available in the Headlamp Plugin Repository.

Offers great UX for both Capsule Admins and Tenant Owners. The plugin provides a dedicated view for Capsule Tenants, allowing users to easily manage their resources and view their usage. It also provides a dedicated view for Capsule Admins, allowing them to easily manage Tenants and view their usage.

Headlamp Plugin Screenshot

Headlamp Plugin Screenshot

Headlamp Plugin Screenshot

Headlamp Plugin Screenshot

1.7 - Kyverno

Capsule integration with Kyverno

Kyverno is a policy engine designed for Kubernetes. It provides the ability to validate, mutate, and generate Kubernetes resources using admission control. Kyverno policies are managed as Kubernetes resources and can be applied to a cluster using kubectl. Capsule integrates with Kyverno to provide a set of policies that can be used to improve the security and governance of the Kubernetes cluster.

Permissions

Some policies are attempting to query Capsule specific information, such as the tenant name based on the namespace. Therefore, we need to ensure that Kyverno has the necessary permissions to read Capsule resources. This can be achieved by extending the Kyverno ClusterRole to include Capsule resources:

admissionController:
  rbac:
    clusterRole:
      extraResources:
       - apiGroups: ["capsule.clastix.io"]
         resources: ["*"]
         verbs: ["get", "list"]

Not all relevant settings are covered by Capsule. We recommend to use Kyverno to enforce additional policies, as their policy implementation is of a very high standard. Here are some policies you might want to consider in multi-tenant environments:

Moved to new page

References

Here are some policies for reference. We do not provide a complete list of policies, but we provide some examples to get you started. This policies are not meant to be used in production. You may adopt principles shown here to create your own policies.

Extract tenant based on namespace

To get the tenant name based on the namespace, you can use a context. With this context we resolve the tenant, based on the {{request.namespace}} for the requested resource. The context calls /api/v1/namespaces/ API with the {{request.namespace}}. The jmesPath is used to check if the tenant label is present. You could assign a default if nothing was found, in this case it’s empty string:

    context:
      - name: tenant_name
        apiCall:
          method: GET
          urlPath: "/api/v1/namespaces/{{request.namespace}}"
          jmesPath: "not_null(metadata.labels.\"capsule.clastix.io/tenant\" || '')"

Select namespaces with label capsule.clastix.io/tenant

When you are performing a policy on namespaced objects, you can select the objects, which are within a tenant namespace by using the namespaceSelector. In this example we select all Kustomization and HelmRelease resources which are within a tenant namespace:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: flux-policies
spec:
  validationFailureAction: Enforce
  rules:
    # Enforcement (Mutate to Default)
    - name: Defaults Kustomizations/HelmReleases
      match:
        any:
        - resources:
            kinds:
              - Kustomization
              - HelmRelease
            operations:
              - CREATE
              - UPDATE
            namespaceSelector:
              matchExpressions:
                - key: "capsule.clastix.io/tenant"
                  operator: Exists
      mutate:
        patchStrategicMerge:
          spec:
            +(targetNamespace): "{{ request.object.metadata.namespace }}"
            +(serviceAccountName): "default"

Compare Source and Destination Tenant

With this policy we try to enforce, that helmreleases within a tenant can only use targetNamespaces, which are within the same tenant or the same namespace the resource is deployed in:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: tenant-compare
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: Validate HelmRelease/Kustomization Target Namespace
      context:

        # Get tenant based on target namespace
        - name: destination_tenant
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.object.spec.targetNamespace}}"
            jmesPath: "metadata.labels.\"capsule.clastix.io/tenant\""

        # Get tenant based on resource namespace
        - name: source_tenant
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.object.metadata.namespace}}"
            jmesPath: "metadata.labels.\"capsule.clastix.io/tenant\""
      match:
        any:
        - resources:
            kinds:
              - HelmRelease
              - Kustomization
            operations:
              - CREATE
              - UPDATE
            namespaceSelector:
              matchExpressions:
                - key: "capsule.clastix.io/tenant"
                  operator: Exists
      preconditions:
        all:
          - key: "{{request.object.spec.targetNamespace}}"
            operator: NotIn
            values: [ "{{request.object.metadata.namespace}}" ]
      validate:
        message: "spec.targetNamespace must be in the same tenant ({{source_tenant}})"
        deny:
          conditions:
            - key: "{{source_tenant}}"
              operator: NotEquals
              value:  "{{destination_tenant}}"

Using Global Configuration

When creating a lot of policies, you might want to abstract your configuration into a global configuration. This is a good practice to avoid duplication and to have a single source of truth. Also if we introduce breaking changes (like changing the label name), we only have to change it in one place. Here is an example of a global configuration:

apiVersion: v1
kind: ConfigMap
metadata:
  name: kyverno-global-config
  namespace: kyverno-system
data:
  # Label for public namespaces
  public_identifier_label: "company.com/public"
  # Value for Label for public namespaces
  public_identifier_value: "yeet"
  # Label which is used to select the tenant name
  tenant_identifier_label: "capsule.clastix.io/tenant"

This configuration can be referenced via context in your policies. Let’s extend the above policy with the global configuration. Additionally we would like to allow the usage of public namespaces:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: tenant-compare
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: Validate HelmRelease/Kustomization Target Namespace
      context:

        # Load Global Configuration
        - name: global
          configMap:
            name: kyverno-global-config
            namespace: kyverno-system

        # Get All Public Namespaces based on the label and it's value from the global configuration
        - name: public_namespaces
          apiCall:
            urlPath: "/api/v1/namespaces"
            jmesPath: "items[?metadata.labels.\"{{global.data.public_identifier_label}}\" == '{{global.data.public_identifier_value}}'].metadata.name | []"

        # Get Tenant information from source namespace
        # Defaults to a character, which can't be a label value
        - name: source_tenant
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.object.metadata.namespace}}"
            jmesPath: "metadata.labels.\"{{global.data.tenant_identifier_label}}\" | '?'"

        # Get Tenant information from destination namespace
        # Returns Array with Tenant Name or Empty
        - name: destination_tenant
          apiCall:
            urlPath: "/api/v1/namespaces"
            jmesPath: "items[?metadata.name == '{{request.object.spec.targetNamespace}}'].metadata.labels.\"{{global.data.tenant_identifier_label}}\""

      preconditions:
        all:
          - key: "{{request.object.spec.targetNamespace}}"
            operator: NotIn
            values: [ "{{request.object.metadata.namespace}}" ]
        any:
          # Source is not Self-Reference
          - key: "{{request.object.spec.targetNamespace}}"
            operator: NotEquals
            value: "{{request.object.metadata.namespace}}"

          # Source not in Public Namespaces
          - key: "{{request.object.spec.targetNamespace}}"
            operator: NotIn
            value: "{{public_namespaces}}"

          # Source not in Destination
          - key: "{{request.object.spec.targetNamespace}}"
            operator: NotIn
            value: "{{destination_tenant}}"
      match:
        any:
        - resources:
            kinds:
              - HelmRelease
              - Kustomization
            operations:
              - CREATE
              - UPDATE
            namespaceSelector:
              matchExpressions:
                - key: "capsule.clastix.io/tenant"
                  operator: Exists
      validate:
        message: "Can not use namespace {{request.object.spec.chart.spec.sourceRef.namespace}} as source reference!"
        deny: {}

Extended Validation and Defaulting

Here’s extended examples for using validation and defaulting. The first policy is used to validate the tenant name. The second policy is used to default the tenant properties, you as cluster-administrator would like to enforce for each tenant.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: tenant-core
spec:
  validationFailureAction: Enforce
  rules:
  - name: tenant-name
    match:
      all:
      - resources:
          kinds:
          - "capsule.clastix.io/v1beta2/Tenant"
          operations:
          - CREATE
          - UPDATE
    validate:
      message: "Using this tenant name is not allowed."
      deny:
        conditions:
          - key: "{{ request.object.metadata.name }}"
            operator: In
            value: ["default", "cluster-system" ]

  - name: tenant-properties
    match:
      any:
      - resources:
          kinds:
          - "capsule.clastix.io/v1beta2/Tenant"
          operations:
          - CREATE
          - UPDATE
    mutate:
      patchesJson6902: |-
        - op: add
          path: "/spec/namespaceOptions/forbiddenLabels/deniedRegex"
          value: ".*company.ch"
        - op: add
          path: "/spec/priorityClasses/matchLabels"
          value:
            consumer: "customer"
        - op: add
          path: "/spec/serviceOptions/allowedServices/nodePort"
          value: false
        - op: add
          path: "/spec/ingressOptions/allowedClasses/matchLabels"
          value:
            consumer: "customer"
        - op: add
          path: "/spec/storageClasses/matchLabels"
          value:
            consumer: "customer"
        - op: add
          path: "/spec/nodeSelector"
          value:
            nodepool: "workers"        

Adding Default Owners/Permissions to Tenant

Since the Owners Spec is a list, it’s a bit more trickier to add a default owner without causing recursions. You must make sure, to validate if the value you are setting is already present. Otherwise you will create a loop. Here is an example of a policy, which adds the cluster:admin as owner to a tenant:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: tenant-policy
spec:
  validationFailureAction: Enforce
  background: true
  rules:

  # With this policy for each tenant cluster:admin is added as owner.
  # Only Append these on CREATE, otherwise they will be added per reconciliation and create a loop.
  - name: tenant-owner
    preconditions:
      all:
      - key: "cluster:admin"
        operator: NotIn
        value: "{{ request.object.spec.owners[?kind == 'Group'].name }}"
    match:
      all:
      - resources:
          kinds:
          - "capsule.clastix.io/v1beta2/Tenant"
          operations:
          - CREATE
          - UPDATE
    mutate:
      patchesJson6902: |-
        - op: add
          path: "/spec/owners/-"
          value:
            name: "cluster:admin"
            kind: "Group"        

  # With this policy for each tenant a default ProxySettings are added.
  # Completely overwrites the ProxySettings, if they are already present.
  - name: tenant-proxy-settings
    match:
      any:
      - resources:
          kinds:
          - "capsule.clastix.io/v1beta2/Tenant"
          operations:
          - CREATE
          - UPDATE
    mutate:
      foreach:
      - list: "request.object.spec.owners"
        patchesJson6902: |-
          - path: /spec/owners/{{elementIndex}}/proxySettings
            op: add
            value:
              - kind: IngressClasses
                operations:
                - List
              - kind: StorageClasses
                operations:
                - List
              - kind: PriorityClasses
                operations:
                - List
              - kind: Nodes
                operations:
                - List          

1.8 - Lens

Capsule extension for Lens

With Capsule extension for Lens, a cluster administrator can easily manage from a single pane of glass all resources of a Kubernetes cluster, including all the Tenants created through the Capsule Operator.

Features

Capsule extension for Lens provides these capabilities:

  • List all tenants
  • See tenant details and change through the embedded Lens editor
  • Check Resources Quota and Budget at both the tenant and namespace level

Please, see the README for details about the installation of the Capsule Lens Extension.

1.9 - Monitoring

Capsule integration with Monitoring Solutions

While we can not provide a full list of all the monitoring solutions available, we can provide some guidance on how to integrate Capsule with some of the most popular ones. Also this is dependent on how you have set up your monitoring solution. We will just explore the options available to you.

Logging

Loki

Promtail

config:
  clients:
    - url: "https://loki.company.com/loki/api/v1/push"
      # Maximum wait period before sending batch
      batchwait: 1s
      # Maximum batch size to accrue before sending, unit is byte
      batchsize: 102400
      # Maximum time to wait for server to respond to a request
      timeout: 10s
      backoff_config:
        # Initial backoff time between retries
        min_period: 100ms
        # Maximum backoff time between retries
        max_period: 5s
        # Maximum number of retries when sending batches, 0 means infinite retries
        max_retries: 20
      tenant_id: "tenant"
      external_labels:
        cluster: "${cluster_name}"
  serverPort: 3101
  positions:
    filename: /run/promtail/positions.yaml
  target_config:
    # Period to resync directories being watched and files being tailed
    sync_period: 10s
  snippets:
    pipelineStages:
      - docker: {}
      # Drop health logs
      - drop:
          expression: "(.*/health-check.*)|(.*/health.*)|(.*kube-probe.*)"
      - static_labels:
          cluster: ${cluster}
      - tenant:
          source: tenant
    # This wont work if pods on the cluster are not labeled with tenant
    extraRelabelConfigs:
      - action: replace
        source_labels:
          - __meta_kubernetes_pod_label_capsule_clastix_io_tenant
        target_label: tenant
...

As mentioned, the above configuration will not work if the pods on the cluster are not labeled with tenant. You can use the following Kyverno policy to ensure that all pods are labeled with tenant. If the pod does not belong to any tenant, it will be labeled with management (assuming you have a central management tenant)

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: capsule-pod-labels
spec:
  background: false
  rules:
  - name: add-pod-label
    context:
      - name: tenant
        apiCall:
          method: GET
          urlPath: "/api/v1/namespaces/{{request.namespace}}"
          jmesPath: "not_null(metadata.labels.\"capsule.clastix.io/tenant\" || 'management')"
    match:
      all:
      - resources:
          kinds:
            - Pod
          operations:
            - CREATE
            - UPDATE
    mutate:
      patchStrategicMerge:
        metadata:
          labels:
            +(capsule.clastix.io/tenant): "{{ tenant_name }}"

Grafana

1.10 - OpenCost

OpenCost Integration for Tenants

This guide explains how to integrate OpenCost with Capsule to provide cost visibility and chargeback/showback per tenant. You can group workloads into tenants by annotating namespaces (for example, opencost.projectcapsule.dev/tenant: {{ tenant.name }}). OpenCost can use this annotation to aggregate costs, enabling accurate cost allocation across clusters, nodes, namespaces, controller kinds, controllers, services, pods, and containers for each tenant.

Prerequisites

Installation

Capsule

  1. Create a tenant with spec.namespaceOptions.additionalMetadataList:
    kubectl create -f - << EOF
    apiVersion: capsule.clastix.io/v1beta2
    kind: Tenant
    metadata:
      name: solar
    spec:
      namespaceOptions:
        additionalMetadataList:
        - annotations:
            opencost.projectcapsule.dev/tenant: "{{ tenant.name }}"
      owners:
      - name: alice
        kind: User
    EOF
    

OpenCost

  1. Create a basic OpenCost values file. Set emitNamespaceAnnotations: true because aggregation is based on the Capsule annotation.
    opencost:
      prometheus:
        internal:
          namespaceName: prometheus-system
          serviceName: prometheus-server
          port: 80
    
      dataRetention:
        dailyResolutionDays: 30  # default: 15
    
      exporter:
        defaultClusterId: kind-opencost-capsule
        replicas: 1
        resources:
          requests:
            cpu: "10m"
            memory: "55Mi"
          limits:
            memory: "1Gi"
        persistence:
          enabled: false
    
      metrics:
        kubeStateMetrics:
          emitNamespaceAnnotations: true
          emitPodAnnotations: true
          emitKsmV1Metrics: false
          emitKsmV1MetricsOnly: false
        serviceMonitor:
          enabled: true
          additionalLabels:
            release: prometheus
    
  2. Install OpenCost with the values above:
    helm install opencost opencost-charts/opencost --namespace opencost --create-namespace -f values.yaml
    

Fetch data from OpenCost

  1. Port-forward:
    kubectl -n opencost port-forward deployment/opencost 9003:9003 9090:9090
    
  2. Query the API:
    • Aggregate by namespace:
      curl -G http://localhost:9003/allocation \
        -d window=1h \
        -d aggregate=namespace,annotation:opencost_projectcapsule_dev_tenant \
        -d resolution=1h
      
    • Aggregate by pod:
      curl -G http://localhost:9003/allocation \
        -d window=1h \
        -d aggregate=pod,annotation:opencost_projectcapsule_dev_tenant \
        -d resolution=1h
      
    • Aggregate by deployment:
      curl -G http://localhost:9003/allocation \
        -d window=1h \
        -d aggregate=deployment,annotation:opencost_projectcapsule_dev_tenant \
        -d resolution=1h
      

1.11 - OpenSearch

Provision OpenSearch tenants, roles, index policies, and writer credentials with Capsule.

OpenSearch can provide a shared logging service for Capsule Tenants. The OpenSearch Kubernetes Operator manages the cluster and its security resources, while Capsule GlobalTenantResources generate the configuration for each participating Tenant. External Secrets Operator supplies passwords from an external secret backend.

This example first installs a shared cluster, then provisions the following resources for a Capsule Tenant named solar:

ResourcePurpose
OpensearchTenantA Dashboards workspace named solar.
OpensearchRoleSeparate permissions for owners, members, viewers, and writers.
OpensearchUserRoleBindingConnect the three human access groups and the static writer user to their roles.
OpenSearchISMPolicyDelete the Tenant’s log indices after 30 days.
ExternalSecret and OpensearchUserMaintain the tenant-solar-writer account using a password stored outside Kubernetes.

A Capsule Tenant groups Kubernetes namespaces. An OpenSearch tenant holds Dashboards saved objects, such as visualizations and index patterns. Index access is controlled separately by OpenSearch roles. This guide configures both boundaries.

flowchart LR
  Tenant[Capsule Tenant] --> GTR[GlobalTenantResources]
  GTR --> Security[OpenSearch tenant, roles, and bindings]
  GTR --> ISM[ISM policy]
  GTR --> User[Writer user]
  GTR --> ES[ExternalSecret]
  Backend[External secret backend] --> ES
  ES --> Secret[Kubernetes Secret]
  Secret --> User
  Security --> Operator[OpenSearch Operator]
  ISM --> Operator
  User --> Operator
  Operator --> Cluster[Shared OpenSearch cluster]

All generated objects live in opensearch-system, a platform-owned namespace outside any Capsule Tenant. Tenant owners must not have Kubernetes write access there: the OpenSearch custom resources can grant access to the shared cluster. In the GlobalTenantResource, set the scope to Tenant. That renders each set of resources once per enabled Capsule Tenant, regardless of how many namespaces it owns.

Prerequisites

Run the example as a cluster administrator. You need:

  • Capsule with support for GlobalTenantResource.spec.scope: Tenant, generators, and Tenant.spec.data; see the installation guide and templating documentation.
  • External Secrets Operator serving external-secrets.io/v1, with a working ClusterSecretStore named platform-opensearch connected to your secret backend.
  • A default StorageClass and capacity for three OpenSearch nodes, each requesting 2 GiB of memory and a 20 GiB volume, plus Dashboards.
  • Helm, kubectl, curl, and jq locally.

The example pins OpenSearch Operator chart 3.0.2 and OpenSearch/Dashboards 3.3.0. It uses the opensearch.org/v1 APIs. Older examples using opensearch.opster.io/v1 need the operator’s API migration guidance.

Set up the OpenSearch cluster

Install the operator

helm repo add opensearch-operator https://opensearch-project.github.io/opensearch-k8s-operator/
helm repo update opensearch-operator
helm upgrade --install opensearch-operator opensearch-operator/opensearch-operator \
  --version 3.0.2 \
  --namespace opensearch-operator-system --create-namespace --wait

kubectl create namespace opensearch-system

The chart’s default watch scope includes opensearch-system. If you restrict the operator’s watched namespaces, include this namespace.

Create the shared cluster

Save this as cluster.yaml:

apiVersion: opensearch.org/v1
kind: OpenSearchCluster
metadata:
  name: shared-search
  namespace: opensearch-system
spec:
  general:
    serviceName: shared-search
    version: "3.3.0"
    httpPort: 9200
    setVMMaxMapCount: true
  security:
    tls:
      transport:
        generate: true
        perNode: true
      http:
        generate: true
  dashboards:
    enable: true
    version: "3.3.0"
    replicas: 1
    resources:
      requests:
        cpu: 200m
        memory: 512Mi
      limits:
        memory: 1Gi
  nodePools:
    - component: nodes
      replicas: 3
      diskSize: 20Gi
      jvm: "-Xms1g -Xmx1g"
      roles:
        - cluster_manager
        - data
        - ingest
      resources:
        requests:
          cpu: 500m
          memory: 2Gi
        limits:
          memory: 2Gi
kubectl apply -f cluster.yaml
kubectl -n opensearch-system get pods -w

Wait for the three OpenSearch nodes and Dashboards to become ready. The operator generates TLS certificates and random credentials in shared-search-admin-password and shared-search-dashboards-password. Its init container sets vm.max_map_count; if your cluster disallows that privileged operation, configure the node setting separately and set setVMMaxMapCount: false. See the pinned operator guide.

This cluster uses the operator’s bundled security configuration to demonstrate provisioning. Before making it available to tenants, review its default users, role mappings, and authentication settings. Avoid managing the same users, roles, or tenants through both a security configuration Secret and operator CRs: applying the configuration can overwrite CR-managed settings. The operator guide explains this interaction under user and role management.

Prepare tenant provisioning

Restrict the secret store

Use a dedicated platform-opensearch store. Configure its existing ClusterSecretStore with the following spec.conditions, keeping its provider and authentication configuration:

spec:
  conditions:
    - namespaces:
        - opensearch-system

This prevents Tenant namespaces from referencing a store that can read every writer password. Conditions are alternatives, so remove broader conditions from this dedicated store. Provider configuration depends on your backend; see ClusterSecretStore.

In that backend, create a record at opensearch/tenants/solar/writer containing a password property with a strong, unique password. Paths in remoteRef.key are relative to the store’s provider configuration, such as a Vault mount. Create a separate record for every Tenant you enable. External Secrets synchronizes an existing password here; it does not generate the backend record.

Give Capsule permission to generate the resources

Use a dedicated replication ServiceAccount. Save this as provisioner.yaml and apply it:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: capsule-opensearch
  namespace: opensearch-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: capsule-opensearch
  namespace: opensearch-system
rules:
  - apiGroups: ["opensearch.org"]
    resources:
      - opensearchtenants
      - opensearchroles
      - opensearchuserrolebindings
      - opensearchismpolicies
      - opensearchusers
    verbs: ["get", "list", "create", "patch", "delete"]
  - apiGroups: ["external-secrets.io"]
    resources: ["externalsecrets"]
    verbs: ["get", "list", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: capsule-opensearch
  namespace: opensearch-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: capsule-opensearch
subjects:
  - kind: ServiceAccount
    name: capsule-opensearch
    namespace: opensearch-system
kubectl apply -f provisioner.yaml

The ServiceAccount manages only the listed custom resources in opensearch-system. External Secrets writes the password Secrets, and the OpenSearch Operator reads them using its own permissions.

Enable OpenSearch for a Capsule Tenant

Save this as tenant.yaml. For an existing Tenant, add spec.data.opensearch to its current manifest, preserving its owners and other settings.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: Group
      name: tenant-solar-owners
  data:
    opensearch:
      enabled: true
      retention: 30d
kubectl apply -f tenant.yaml

spec.data.opensearch.enabled is a boolean switch. All three generators below default it to false when it is absent, including when the entire opensearch or data section is missing. Set it to true to provision access, retention, and writer credentials together. retention is required only for enabled Tenants.

Role names and backend group names are derived from metadata.name: tenant-solar-owners, tenant-solar-members, and tenant-solar-viewers. Configure your identity provider to supply these group names to OpenSearch. Capsule’s Kubernetes owner assignment does not authenticate a group to OpenSearch or assign its OpenSearch role. The static writer account uses OpenSearch’s internal user database and can be tested independently.

The platform controls the enable switch and retention configuration. Keep Tenant names stable: they determine resource names, identity groups, credential paths, and index patterns. No role names are read from Tenant data.

Generate the OpenSearch tenant, roles, and bindings

Save this as opensearch-access.yaml. The four roles provide the following access:

RoleIndex permissionsDashboards workspace
tenant-solar-writersRead, ingest, update, delete, and manage ingestion-related operations on live log indices.No access.
tenant-solar-ownersRead and manage live log indices and restored snapshot indices, including index deletion.Read and write saved objects.
tenant-solar-membersRead and monitor live log indices and restored snapshot indices.Read and write saved objects.
tenant-solar-viewersRead and monitor live log indices and restored snapshot indices.Read saved objects.

The writer role supports shippers that also read, refresh, manage aliases, and delete data. It is a broader service role than an ingestion-only account. Owners manage indices; manage does not itself grant document ingestion permissions.

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: opensearch-access
spec:
  scope: Tenant
  resyncPeriod: 60s
  serviceAccount:
    name: capsule-opensearch
    namespace: opensearch-system
  resources:
    - additionalMetadata:
        labels:
          observability.example.com/tenant: "{{tenant.name}}"
          app.kubernetes.io/name: "{{tenant.name}}"
          app.kubernetes.io/part-of: tenant
          app.kubernetes.io/component: logging
      generators:
        - missingKey: error
          template: |
            {{- $spec := index $.tenant "spec" | default dict }}
            {{- $data := index $spec "data" | default dict }}
            {{- $opensearch := index $data "opensearch" | default dict }}
            {{- if (index $opensearch "enabled") }}
            apiVersion: opensearch.org/v1
            kind: OpensearchTenant
            metadata:
              name: {{ $.tenant.metadata.name | quote }}
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              description: "Dashboards workspace for Capsule Tenant {{ $.tenant.metadata.name }}"
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchRole
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-writers
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              clusterPermissions:
                - cluster_composite_ops
                - cluster_monitor
              indexPermissions:
                - indexPatterns:
                    - "tenant-{{ $.tenant.metadata.name }}_*"
                  allowedActions:
                    - read
                    - index
                    - create_index
                    - "indices:admin/get"
                    - "indices:admin/refresh*"
                    - "indices:admin/delete"
                    - "indices:admin/auto_create"
                    - "indices:admin/aliases*"
                    - "indices:admin/exists"
                    - "indices:admin/mappings/get"
                    - "indices:data/write/bulk*"
                    - "indices:data/write/delete*"
                    - "indices:data/read/scroll"
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchRole
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-owners
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              clusterPermissions:
                - cluster_composite_ops
                - cluster_monitor
              indexPermissions:
                - indexPatterns:
                    - "tenant-{{ $.tenant.metadata.name }}_*"
                    - "remote_snapshot_tenant-{{ $.tenant.metadata.name }}_*"
                  allowedActions:
                    - read
                    - manage
              tenantPermissions:
                - tenantPatterns:
                    - {{ $.tenant.metadata.name | quote }}
                  allowedActions:
                    - kibana_all_read
                    - kibana_all_write
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchRole
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-members
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              clusterPermissions:
                - cluster_composite_ops
              indexPermissions:
                - indexPatterns:
                    - "tenant-{{ $.tenant.metadata.name }}_*"
                    - "remote_snapshot_tenant-{{ $.tenant.metadata.name }}_*"
                  allowedActions:
                    - read
                    - indices_monitor
              tenantPermissions:
                - tenantPatterns:
                    - {{ $.tenant.metadata.name | quote }}
                  allowedActions:
                    - kibana_all_read
                    - kibana_all_write
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchRole
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-viewers
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              clusterPermissions:
                - cluster_composite_ops_ro
              indexPermissions:
                - indexPatterns:
                    - "tenant-{{ $.tenant.metadata.name }}_*"
                    - "remote_snapshot_tenant-{{ $.tenant.metadata.name }}_*"
                  allowedActions:
                    - read
                    - indices_monitor
              tenantPermissions:
                - tenantPatterns:
                    - {{ $.tenant.metadata.name | quote }}
                  allowedActions:
                    - kibana_all_read
            {{- range $role := list "owners" "members" "viewers" }}
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchUserRoleBinding
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-{{ $role }}
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              backendRoles:
                - tenant-{{ $.tenant.metadata.name }}-{{ $role }}
              roles:
                - tenant-{{ $.tenant.metadata.name }}-{{ $role }}
                - kibana_user
                {{- if eq $role "viewers" }}
                - kibana_read_only
                {{- end }}
            {{- end }}
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchUserRoleBinding
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-writers
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              users:
                - tenant-{{ $.tenant.metadata.name }}-writer
              roles:
                - tenant-{{ $.tenant.metadata.name }}-writers
            {{- end }}            

The underscore in tenant-solar_* separates the Tenant name from the rest of the index name. Capsule Tenant names cannot contain underscores, so solar and solar-prod receive distinct patterns. A pattern such as tenant-solar-* would also match tenant-solar-prod-*. Use the underscore convention consistently in shippers, policies, and snapshot restore names. The remote_snapshot_tenant-solar_* pattern permits access to restored indices following that convention; this guide does not create snapshots or restore them.

The human bindings include the built-in kibana_user role for Dashboards access. The custom roles use tenantPermissions for the named workspace, without adding broad .dashboard_* or .kibana* patterns. Viewers also receive kibana_read_only for the default Dashboards read-only UI mode. Their index and tenant permissions enforce read-only access independently of that UI setting. Permissions from multiple roles accumulate, so a viewer who also belongs to the owners group still has owner permissions through the API. See users and roles.

indices_monitor belongs under indexPermissions, where it is scoped to the Tenant’s indices. cluster_monitor grants cluster-wide monitoring to owners and writers. The writer’s wildcard actions include refresh and delete-by-query operations. See the action group definitions.

If your platform defines a custom action group such as cluster-reports-access, add it to the owners’ and members’ clusterPermissions after provisioning that group. It is not a built-in action group, so this example does not depend on it. Review report access separately from index and Dashboards tenant access.

kubectl apply -f opensearch-access.yaml

Generate ISM policies

Index State Management manages the lifetime of indices. This example uses daily indices such as tenant-solar_2026.09.22, then deletes each index when its age reaches the Tenant’s retention period. Index age is measured from index creation, not from timestamps inside documents. Restored indices using the remote_snapshot_tenant- prefix do not match this retention policy.

Save this as opensearch-retention.yaml:

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: opensearch-retention
spec:
  scope: Tenant
  resyncPeriod: 60s
  serviceAccount:
    name: capsule-opensearch
    namespace: opensearch-system
  resources:
    - additionalMetadata:
        labels:
          observability.example.com/tenant: "{{tenant.name}}"
          app.kubernetes.io/name: "{{tenant.name}}"
          app.kubernetes.io/part-of: tenant
          app.kubernetes.io/component: logging
      generators:
        - missingKey: error
          template: |
            {{- $spec := index $.tenant "spec" | default dict }}
            {{- $data := index $spec "data" | default dict }}
            {{- $opensearch := index $data "opensearch" | default dict }}
            {{- if (index $opensearch "enabled") }}
            apiVersion: opensearch.org/v1
            kind: OpenSearchISMPolicy
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-logs
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              description: "Log retention for Capsule Tenant {{ $.tenant.metadata.name }}"
              defaultState: hot
              applyToExistingIndices: false
              ismTemplate:
                indexPatterns:
                  - "tenant-{{ $.tenant.metadata.name }}_*"
                priority: 100
              states:
                - name: hot
                  actions: []
                  transitions:
                    - stateName: delete
                      conditions:
                        minIndexAge: {{ $.tenant.spec.data.opensearch.retention | quote }}
                - name: delete
                  actions:
                    - delete: {}
            {{- end }}            
kubectl apply -f opensearch-retention.yaml

ismTemplate attaches the policy to newly created matching indices. applyToExistingIndices: false leaves older indices untouched. Enable it only when you intend to apply retention to existing data. Changing a policy does not necessarily change every already-managed index immediately; inspect ISM state and use the ISM APIs when migrating existing indices. This policy does not perform rollover: the shipper must select a new daily index.

Generate static writer users with External Secrets

Save this as opensearch-writers.yaml. For each enabled Tenant, Capsule creates an ExternalSecret and an OpensearchUser. External Secrets reads the password into a Secret, and the OpenSearch Operator uses passwordFrom to configure the internal user. The access generator binds the singular tenant-solar-writer user to the plural tenant-solar-writers role.

apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
  name: opensearch-writers
spec:
  scope: Tenant
  resyncPeriod: 60s
  serviceAccount:
    name: capsule-opensearch
    namespace: opensearch-system
  resources:
    - additionalMetadata:
        labels:
          observability.example.com/tenant: "{{tenant.name}}"
          app.kubernetes.io/name: "{{tenant.name}}"
          app.kubernetes.io/part-of: tenant
          app.kubernetes.io/component: logging
      generators:
        - missingKey: error
          template: |
            {{- $spec := index $.tenant "spec" | default dict }}
            {{- $data := index $spec "data" | default dict }}
            {{- $opensearch := index $data "opensearch" | default dict }}
            {{- if (index $opensearch "enabled") }}
            apiVersion: external-secrets.io/v1
            kind: ExternalSecret
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-writer
              namespace: opensearch-system
            spec:
              refreshPolicy: Periodic
              refreshInterval: 1h
              secretStoreRef:
                kind: ClusterSecretStore
                name: platform-opensearch
              target:
                name: tenant-{{ $.tenant.metadata.name }}-writer
                creationPolicy: Owner
                template:
                  engineVersion: v2
                  mergePolicy: Merge
                  metadata:
                    annotations:
                      opensearchuser/name: tenant-{{ $.tenant.metadata.name }}-writer
                      opensearchuser/namespace: opensearch-system
                  data:
                    username: tenant-{{ $.tenant.metadata.name }}-writer
                    endpoint: https://shared-search.opensearch-system.svc:9200
              data:
                - secretKey: password
                  remoteRef:
                    key: opensearch/tenants/{{ $.tenant.metadata.name }}/writer
                    property: password
            ---
            apiVersion: opensearch.org/v1
            kind: OpensearchUser
            metadata:
              name: tenant-{{ $.tenant.metadata.name }}-writer
              namespace: opensearch-system
            spec:
              opensearchCluster:
                name: shared-search
              passwordFrom:
                name: tenant-{{ $.tenant.metadata.name }}-writer
                key: password
            {{- end }}            
kubectl apply -f opensearch-writers.yaml

ESO’s mergePolicy: Merge preserves the fetched password alongside the generated username and endpoint. No password passes through the Capsule template or needs to be committed to Git.

The Secret annotations identify the user to reconcile when its password changes. Set both explicitly because this Secret has multiple data keys; otherwise the operator’s multi-user Secret handling can interpret those keys as usernames. This follows the pinned operator’s Secret watch implementation.

The OpenSearch resources and their password Secrets use the cluster’s namespace. Their opensearchCluster and passwordFrom references are local references. The controllers reconcile asynchronously, so a user or binding may initially be pending while its dependencies are created.

Platform-managed log shippers in opensearch-system can mount the corresponding writer Secret. Configure each output with that Tenant’s credentials and index prefix, and trust the cluster’s public CA certificate. Route logs using trusted Kubernetes namespace ownership, rather than a tenant-supplied field in a log message. If shippers run in Tenant namespaces, distribute only the matching writer Secret through a separately scoped GlobalTenantResource replication; keep the platform secret store restricted.

Verify the integration

First check Capsule’s generation and each downstream controller:

kubectl wait --for=condition=Ready --timeout=120s \
  globaltenantresource/opensearch-access \
  globaltenantresource/opensearch-retention \
  globaltenantresource/opensearch-writers

kubectl -n opensearch-system wait --for=condition=Ready --timeout=120s \
  externalsecret/tenant-solar-writer

kubectl -n opensearch-system get \
  opensearchtenants.opensearch.org,opensearchroles.opensearch.org,opensearchuserrolebindings.opensearch.org,opensearchismpolicies.opensearch.org,opensearchusers.opensearch.org

Capsule’s Ready condition means the Kubernetes objects were reconciled. Wait for the OpenSearch resources to report status.state: CREATED before testing access. If they do not, inspect their status.reason and controller logs. Existing unmanaged objects with the same names can prevent the operator from taking ownership; use fresh names for this example.

In a separate terminal, expose the REST service locally:

kubectl -n opensearch-system port-forward svc/shared-search 9200:9200

Fetch only the public CA certificate, then use the service’s certificate hostname with the forwarded connection:

kubectl -n opensearch-system get secret shared-search-http-cert -o json \
  | jq -r '.data["ca.crt"] | @base64d' > opensearch-ca.crt

OS_URL=https://shared-search.opensearch-system.svc:9200
OS_CONNECT=shared-search.opensearch-system.svc:9200:localhost:9200

The following commands prompt for the writer password from your external backend; they do not place it in shell history. Writing its own Tenant’s logs should return HTTP 201, writing another Tenant’s logs should return HTTP 403, and searching its own logs should return HTTP 200:

# Allowed: ingest into this Tenant's index.
curl --cacert opensearch-ca.crt --connect-to "$OS_CONNECT" \
  --user tenant-solar-writer -w '\nHTTP %{http_code}\n' \
  -H 'Content-Type: application/json' \
  -X POST "$OS_URL/tenant-solar_2026.09.22/_doc" \
  -d '{"message":"hello from solar"}'

# Denied: ingest into another Tenant's index.
curl --cacert opensearch-ca.crt --connect-to "$OS_CONNECT" \
  --user tenant-solar-writer -w '\nHTTP %{http_code}\n' \
  -H 'Content-Type: application/json' \
  -X POST "$OS_URL/tenant-wind_2026.09.22/_doc" \
  -d '{"message":"must be rejected"}'

# Allowed: this writer role includes read access to its Tenant's logs.
curl --cacert opensearch-ca.crt --connect-to "$OS_CONNECT" \
  --user tenant-solar-writer -w '\nHTTP %{http_code}\n' \
  "$OS_URL/tenant-solar_2026.09.22/_search"

As a platform administrator, inspect the index’s ISM policy. Retrieve the bootstrap admin password from shared-search-admin-password and enter it at the prompt:

curl --cacert opensearch-ca.crt --connect-to "$OS_CONNECT" \
  --user admin \
  "$OS_URL/_plugins/_ism/explain/tenant-solar_2026.09.22?show_policy=true"

Allow time for ISM’s background processing. The response should identify tenant-solar-logs as the policy. For interactive access, forward svc/shared-search-dashboards on port 5601, authenticate through your configured identity provider, and select solar. As an owner or member, create an index pattern for tenant-solar_* and save a visualization. As a viewer, verify that you can read those saved objects but cannot edit them. Use separate identities for each access level, since OpenSearch combines their role permissions. Verify that none of these identities can access another Tenant’s logs or workspace.

Rotation and tenant lifecycle

To rotate a writer password, update its existing backend record. ESO refreshes the Kubernetes Secret, and the OpenSearch Operator reconciles the internal user. You can request an immediate ESO refresh with:

kubectl -n opensearch-system annotate externalsecret tenant-solar-writer \
  force-sync="$(date +%s)" --overwrite

Reload or restart shippers that do not watch credential changes. Secret updates, OpenSearch user updates, and client reloads are separate operations, so a single static account can experience a short authentication interruption during rotation.

To onboard another Tenant, create its backend password record, configure the three derived identity groups, and set spec.data.opensearch.enabled: true with a retention period. The three GlobalTenantResources provision its resources automatically.

To disable this integration for a Tenant, set the switch to false:

kubectl patch tenant solar --type=merge \
  -p '{"spec":{"data":{"opensearch":{"enabled":false}}}}'

All three generators then produce no objects for that Tenant, allowing Capsule to prune previously generated resources according to its object management rules. Removing the switch has the same effect. This withdraws the desired access resources and retention policy; it is not a pause that preserves them. Keep the OpenSearch Operator and shared cluster available while security-resource finalizers run. Deleting access resources is separate from deleting stored log indices, Dashboards data, and backend password records; include those in your platform’s offboarding process and verify that access has been revoked.

1.12 - Openshift

1.13 - Rancher

1.14 - Tekton

Capsule integration with Tekton

With Capsule extension for Lens, a cluster administrator can easily manage from a single pane of glass all resources of a Kubernetes cluster, including all the Tenants created through the Capsule Operator.

Prerequisites

Tekton must be already installed on your cluster, if that’s not the case consult the documentation here:

Cluster Scoped Permissions

Tekton Dashboard

Now for the enduser experience we are going to deploy the tekton dashboard. When using oauth2-proxy we can deploy one single dashboard, which can be used for all tenants. Refer to the following guide to setup the dashboard with the oauth2-proxy:

Once that is done, we need to make small adjustments to the tekton-dashboard service account.

kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - https://storage.googleapis.com/tekton-releases/dashboard/latest/release.yaml
patches:
  # Adjust the service for the capsule-proxy according to your installation
  # The used values are compatbile with the default installation values
  - target:
      version: v1
      kind: Deployment
      name: tekton-dashboard
    patch: |-
      - op: add
        path: /spec/template/spec/containers/0/env/-
        value:
          name: KUBERNETES_SERVICE_HOST
          value: "capsule-proxy.capsule-system.svc"
      - op: add
        path: /spec/template/spec/containers/0/env/-
        value:
          name: KUBERNETES_SERVICE_PORT
          value: "9001"      

  # Adjust the CA certificate for the capsule-proxy according to your installation
  - target:
      version: v1
      kind: Deployment
      name: tekton-dashboard
    patch: |-
      - op: add
        path: /spec/template/spec/containers/0/volumeMounts
        value: []
      - op: add
        path: /spec/template/spec/containers/0/volumeMounts/-
        value:
          mountPath: "/var/run/secrets/kubernetes.io/serviceaccount"
          name: token-ca
      - op: add
        path: /spec/template/spec/volumes
        value: []
      - op: add
        path: /spec/template/spec/volumes/-
        value:
          name: token-ca
          projected:
            sources:
              - serviceAccountToken:
                  expirationSeconds: 86400
                  path: token
              - secret:
                  name: capsule-proxy
                  items:
                    - key: ca
                      path: ca.crt      

This patch assumes there’s a secret called capsule-proxy with the CA certificate for the Capsule Proxy URL.

Apply the given kustomization:

extraEnv:

  • name: KUBERNETES_SERVICE_HOST value: ‘${CAPSULE_PROXY_URL}’
  • name: KUBERNETES_SERVICE_PORT value: ‘${CAPSULE_PROXY_PORT}’

Tekton Operator

When using the Tekton Operator, you need to add the following to the TektonConfig:

apiVersion: operator.tekton.dev/v1alpha1
kind: TektonConfig
metadata:
  name: config
spec:
  dashboard:
    readonly: false
    options:
      disabled: false
      deployments:
        tekton-dashboard:
          spec:
            template:
              spec:
                volumes:
                  - name: token-ca
                    projected:
                      sources:
                        - serviceAccountToken:
                            expirationSeconds: 86400
                            path: token
                        - secret:
                            name: capsule-proxy
                            items:
                              - key: ca
                                path: ca.crt
                containers:
                  - name: tekton-dashboard
                    volumeMounts:
                      - mountPath: "/var/run/secrets/kubernetes.io/serviceaccount"
                        name: token-ca
                    env:
                      - name: KUBERNETES_SERVICE_HOST
                        value: "capsule-proxy.capsule-system.svc"
                      - name: KUBERNETES_SERVICE_PORT
                        value: "9001"

See for reference the options spec

1.15 - Teleport

Capsule Proxy integration with Teleport

Teleport is an open-source tool that provides zero trust access to servers and cloud applications using SSH, Kubernetes, Database, Remote Desktop Protocol and HTTPS. It can eliminate the need for VPNs by providing a single gateway to access computing infrastructure via SSH, Kubernetes clusters, and cloud applications via a built-in proxy.1

If you want to pass requests from teleport users through the capsule-proxy for users to be able to do things like listing namespaces scoped to their own tenants, this integration is for you.

Prerequisites

  1. Capsule
  2. Capsule Proxy
  3. Teleport Cluster
  4. teleport-kube-agent

Integration

It’s recommended to install teleport-kube-agent in the capsule-system namespace. Otherwise you need to somehow replicate the internal ca secret to the namespace, where teleport-kube-agent is deployed to. For this case Cert-Manager Trust-Bundles might be useful.

Add the following values to the teleport-kube-agent helm chart and you’re already done:

extraEnv:
  - name: KUBERNETES_SERVICE_HOST
    value: capsule-proxy.capsule-system.svc
  - name: KUBERNETES_SERVICE_PORT
    value: "9001"
extraVolumes:
  - name: kube-api-access-capsule
    projected:
      sources:
        - serviceAccountToken:
            path: token
        - secret:
            items:
              - key: ca
                path: ca.crt
            name: capsule-proxy
        - downwardAPI:
            items:
              - fieldRef:
                  apiVersion: v1
                  fieldPath: metadata.namespace
                path: namespace
extraVolumeMounts:
  - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
    name: kube-api-access-capsule
    readOnly: true

Note: The secret capsule-proxy refers to the secret which is being used by the capsule-proxy instance directly, not the self-signed-ca secret.

Local Demo

If you want to test this integration locally, follow these steps.

References

Tools

The following tools have to be installed on your machine:

  • docker
  • kind
  • kubectl
  • helm
  • mkcert

Docker Network

Create docker network teleport:

docker network create teleport

Self-signed certificates

Create certificates for teleport.demo:

mkdir teleport-tls
cd teleport-tls
mkcert teleport.demo "*.teleport.demo"
cp "$(mkcert -CAROOT)/rootCA.pem" .

Teleport installation

  • Run Ubuntu docker image in the teleport network using teleport.demo alias on port 443:

    docker run -it -v .:/etc/teleport-tls --name teleport --network teleport --network-alias teleport.demo -p 443:443 ubuntu:22.04
    
  • Run the following commands inside docker container:

    • apt-get update && apt-get install -y curl

    • cp /etc/teleport-tls/rootCA.pem /etc/ssl/certs/mkcertCA.pem

    • curl https://cdn.teleport.dev/install.sh | bash -s 18.2.1

    • teleport configure -o file \
        --cluster-name=teleport.demo \
        --public-addr=teleport.demo:443 \
        --cert-file=/etc/teleport-tls/teleport.demo+1.pem \
        --key-file=/etc/teleport-tls/teleport.demo+1-key.pem
      
    • teleport start --config="/etc/teleport.yaml"

  • Open new shell

  • Note down IP of docker container:

    docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' teleport
    

Kubernetes Cluster setup

  • Create kind cluster: kind create cluster --name capsule

CoreDNS

To allow pods to easily connect to the teleport service running in the other Docker container:

  • Connect to docker network: docker network connect teleport capsule-control-plane

  • Edit ConfigMap of coredns to set up dns resolution of teleport.demo: kubectl edit cm -n kube-system coredns

    • hosts {
          <Paste IP from docker inspect command here> teleport.demo
          fallthrough
      }
      
  • Restart coredns Deployment: kubectl rollout restart deployment -n kube-system coredns

Capsule

capsule-values.yaml:

manager:
  options:
    capsuleUserGroups: ["tenant-wind"]
    forceTenantPrefix: true

tenant.yaml:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: wind
spec:
  owners:
  - name: alice
    kind: User

Install capsule with tenant-oil as a capsule user group via helm chart:

  • helm repo add projectcapsule https://projectcapsule.github.io/charts
  • helm upgrade --install capsule -n capsule-system --create-namespace projectcapsule/capsule --version 0.10.9 -f capsule-values.yaml
  • Create tenant named wind: kubectl apply -f tenant.yaml

Capsule Proxy

Install default capsule-proxy via helm chart:

  • helm repo add projectcapsule https://projectcapsule.github.io/charts
  • helm upgrade --install capsule-proxy -n capsule-system projectcapsule/capsule-proxy --version 0.9.12

Teleport

Create teleport role for kubernetes cluster access which adds tenant-wind group to user auth token.

  • docker exec -it teleport bash

  • cat <<EOF > role.yaml
    kind: role
    metadata:
      labels:
        capsule: "true"
      name: kube-access
    version: v8
    spec:
      allow:
        kubernetes_groups:
        - tenant-wind
        kubernetes_labels:
          capsule: "true"
        kubernetes_resources:
        - api_group: '*'
          kind: '*'
          name: '*'
          namespace: '*'
          verbs:
          - '*'
        kubernetes_users:
        - alice
    EOF
    
  • tctl create role.yaml

Create and set up user alice with kube-access teleport role:

  • tctl users add alice --roles=access,kube-access
  • Add 127.0.0.1 teleport.demo to etc/hosts of your computer
  • Set password and second factor for user alice in browser

Create join token for teleport-kube-agent:

  • Note down authToken from command: tctl tokens add --type=kube --ttl=24h

Teleport Agent

teleport-agent-values.yaml:

proxyAddr: "teleport.demo:443"
kubeClusterName: "teleport.demo"
insecureSkipProxyTLSVerify: true
authToken: "<Paste authToken from tctl tokens add command here>"
labels:
  capsule: "true"
extraEnv:
  - name: KUBERNETES_SERVICE_HOST
    value: capsule-proxy.capsule-system.svc
  - name: KUBERNETES_SERVICE_PORT
    value: "9001"
extraVolumes:
  - name: kube-api-access-capsule
    projected:
      sources:
        - serviceAccountToken:
            path: token
        - secret:
            items:
              - key: ca
                path: ca.crt
            name: capsule-proxy
        - downwardAPI:
            items:
              - fieldRef:
                  apiVersion: v1
                  fieldPath: metadata.namespace
                path: namespace
extraVolumeMounts:
  - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
    name: kube-api-access-capsule
    readOnly: true
  • Update authToken in teleport-agent-values.yaml from output of tctl tokens add command
  • helm repo add teleport https://charts.releases.teleport.dev
  • helm upgrade --install teleport-agent -n capsule-system teleport/teleport-kube-agent --version 18.2.0 -f teleport-agent-values.yaml

Test it out

  • tsh login --proxy=teleport.demo:443 --auth=local --user=alice teleport.demo
  • tsh kube login teleport.demo
  • kubectl get tenant
  • kubectl get namespace (only works because teleport is connected to capsule-proxy instead of kubernetes api)
  • kubectl create ns foo-bar (should fail, since not owner)
  • kubectl create ns oil-bar (should succeed)

From here you could enable ProxyClusterScoped feature gate to allow listing of cluster scoped resources via ProxySettings.

Cleanup

  • kind delete clusters capsule
  • rm -rf teleport-tls
  • tsh logout --proxy=teleport.demo --user alice
  • docker rm teleport

1.16 - Velero