mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): admit plugin secret action jobs
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
# Example only. Copy into a private overlay and replace the placeholder with
|
||||
# the exact Kubernetes API endpoint CIDR. The base policy intentionally has no
|
||||
# broad TCP/443 escape hatch, so the controller fails closed without this rule.
|
||||
- op: add
|
||||
path: /spec/egress/-
|
||||
value:
|
||||
to:
|
||||
- ipBlock:
|
||||
cidr: REPLACE_WITH_API_SERVER_CIDR
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
+77
-1
@@ -26,7 +26,7 @@ spec:
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
spec:
|
||||
serviceAccountName: ql3-plugin-package-executor
|
||||
automountServiceAccountToken: false
|
||||
automountServiceAccountToken: true
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
@@ -65,6 +65,82 @@ spec:
|
||||
value: '16'
|
||||
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES
|
||||
value: '16'
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED
|
||||
value: 'true'
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT
|
||||
value: '8'
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_NAMESPACE
|
||||
value: qinglong3-system
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_SERVICE_ACCOUNT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: serviceAccountName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_SOURCE_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: sourceSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: image
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresCaSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresCaKey
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresServerName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresUrlSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresUrlSecretKey
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_AUTH_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresAuthSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresHost
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresPort
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_DATABASE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresDatabase
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_USERNAME_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresUsernameKey
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PASSWORD_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresPasswordKey
|
||||
- name: QL3_POSTGRES_TLS_MODE
|
||||
value: verify-full
|
||||
- name: QL3_POSTGRES_TLS_CA_FILE
|
||||
|
||||
+6
@@ -3,5 +3,11 @@ kind: Kustomization
|
||||
|
||||
resources:
|
||||
- service-account.yaml
|
||||
- secret-action-service-account.yaml
|
||||
- secret-action-admission-config.yaml
|
||||
- role.yaml
|
||||
- role-binding.yaml
|
||||
- validating-admission-policy.yaml
|
||||
- validating-admission-policy-binding.yaml
|
||||
- cron-job.yaml
|
||||
- network-policy.yaml
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: ql3-plugin-package-executor
|
||||
namespace: qinglong3-system
|
||||
labels:
|
||||
app.kubernetes.io/name: ql3-plugin-package-executor
|
||||
app.kubernetes.io/component: plugin-package-executor
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: ql3-plugin-package-executor
|
||||
namespace: qinglong3-system
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: ql3-plugin-package-executor
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: ql3-plugin-package-executor
|
||||
namespace: qinglong3-system
|
||||
labels:
|
||||
app.kubernetes.io/name: ql3-plugin-package-executor
|
||||
app.kubernetes.io/component: plugin-package-executor
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
rules:
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- jobs
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- apiGroups:
|
||||
- ''
|
||||
resources:
|
||||
- configmaps
|
||||
resourceNames:
|
||||
- ql3-plugin-package-secret-action-admission
|
||||
verbs:
|
||||
- get
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
namespace: qinglong3-system
|
||||
labels:
|
||||
app.kubernetes.io/name: ql3-plugin-package-secret-action
|
||||
app.kubernetes.io/component: plugin-package-executor
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
data:
|
||||
# Release automation must replace this unavailable digest together with the
|
||||
# controller image before the opt-in operation can execute.
|
||||
image: registry.example.com/qinglong/qinglong3-cluster-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
serviceAccountName: ql3-plugin-package-secret-action
|
||||
sourceSecretName: ql3-cluster-plugin-package-values
|
||||
postgresConnectionMode: url
|
||||
postgresCaSecretName: ql3-cluster-plugin-package-executor
|
||||
postgresCaKey: postgres-ca.crt
|
||||
postgresServerName: REPLACE_WITH_POSTGRES_DNS_NAME
|
||||
postgresUrlSecretName: ql3-cluster-plugin-package-executor
|
||||
postgresUrlSecretKey: postgres-package-executor-url
|
||||
postgresAuthSecretName: ''
|
||||
postgresHost: ''
|
||||
postgresPort: ''
|
||||
postgresDatabase: ''
|
||||
postgresUsernameKey: ''
|
||||
postgresPasswordKey: ''
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: ql3-plugin-package-secret-action
|
||||
namespace: qinglong3-system
|
||||
labels:
|
||||
app.kubernetes.io/name: ql3-plugin-package-secret-action
|
||||
app.kubernetes.io/component: plugin-package-executor
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
automountServiceAccountToken: false
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
apiVersion: admissionregistration.k8s.io/v1
|
||||
kind: ValidatingAdmissionPolicyBinding
|
||||
metadata:
|
||||
name: ql3-plugin-package-secret-action
|
||||
labels:
|
||||
app.kubernetes.io/name: ql3-plugin-package-secret-action
|
||||
app.kubernetes.io/component: plugin-package-executor
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
spec:
|
||||
policyName: ql3-plugin-package-secret-action
|
||||
paramRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
namespace: qinglong3-system
|
||||
parameterNotFoundAction: Deny
|
||||
matchResources:
|
||||
namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: qinglong3-system
|
||||
validationActions:
|
||||
- Deny
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
apiVersion: admissionregistration.k8s.io/v1
|
||||
kind: ValidatingAdmissionPolicy
|
||||
metadata:
|
||||
name: ql3-plugin-package-secret-action
|
||||
labels:
|
||||
app.kubernetes.io/name: ql3-plugin-package-secret-action
|
||||
app.kubernetes.io/component: plugin-package-executor
|
||||
app.kubernetes.io/part-of: qinglong3
|
||||
spec:
|
||||
failurePolicy: Fail
|
||||
paramKind:
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
matchConstraints:
|
||||
resourceRules:
|
||||
- apiGroups:
|
||||
- batch
|
||||
apiVersions:
|
||||
- v1
|
||||
operations:
|
||||
- CREATE
|
||||
resources:
|
||||
- jobs
|
||||
scope: Namespaced
|
||||
matchConditions:
|
||||
- name: exact-controller-identity
|
||||
expression: request.userInfo.username == 'system:serviceaccount:qinglong3-system:ql3-plugin-package-executor'
|
||||
variables:
|
||||
- name: pod
|
||||
expression: object.spec.template.spec
|
||||
- name: executor
|
||||
expression: variables.pod.containers[0]
|
||||
- name: values
|
||||
expression: variables.pod.volumes.filter(v, v.name == 'plugin-package-values')[0]
|
||||
- name: ca
|
||||
expression: variables.pod.volumes.filter(v, v.name == 'postgres-ca')[0]
|
||||
validations:
|
||||
- expression: >-
|
||||
object.metadata.namespace == 'qinglong3-system' &&
|
||||
object.metadata.name.matches('^ql3-package-secret-[0-9a-f]{32}$') &&
|
||||
object.metadata.name == 'ql3-package-secret-' + object.metadata.annotations['qinglong.io/secret-action-job-digest'].substring(0, 32) &&
|
||||
object.metadata.labels['app.kubernetes.io/name'] == 'ql3-plugin-package-secret-action' &&
|
||||
object.metadata.labels['app.kubernetes.io/component'] == 'plugin-package-executor' &&
|
||||
object.metadata.labels['app.kubernetes.io/part-of'] == 'qinglong3' &&
|
||||
object.metadata.annotations['qinglong.io/secret-action-job-schema'] == 'qinglong/plugin-package-kubernetes-secret-action-job@v1' &&
|
||||
object.metadata.annotations['qinglong.io/secret-action-job-digest'].matches('^[0-9a-f]{64}$') &&
|
||||
object.metadata.annotations['qinglong.io/approved-action-digest'].matches('^[0-9a-f]{64}$') &&
|
||||
object.metadata.annotations['qinglong.io/approved-action-type'] in ['plugin_package.secret_binding.bind', 'plugin_package.secret_binding.transition']
|
||||
message: Secret action Job metadata is outside the reviewed contract
|
||||
- expression: >-
|
||||
object.spec.backoffLimit == 2 &&
|
||||
object.spec.activeDeadlineSeconds == 600 &&
|
||||
object.spec.ttlSecondsAfterFinished == 3600 &&
|
||||
variables.pod.serviceAccountName == params.data.serviceAccountName &&
|
||||
object.spec.template.metadata.labels['app.kubernetes.io/name'] == 'ql3-plugin-package-secret-action' &&
|
||||
object.spec.template.metadata.labels['app.kubernetes.io/component'] == 'plugin-package-executor' &&
|
||||
object.spec.template.metadata.labels['app.kubernetes.io/part-of'] == 'qinglong3' &&
|
||||
object.spec.template.metadata.labels['qinglong.io/secret-action-job'] == object.metadata.name &&
|
||||
variables.pod.automountServiceAccountToken == false &&
|
||||
variables.pod.enableServiceLinks == false &&
|
||||
variables.pod.restartPolicy == 'Never' &&
|
||||
(!has(variables.pod.hostNetwork) || variables.pod.hostNetwork == false) &&
|
||||
(!has(variables.pod.hostPID) || variables.pod.hostPID == false) &&
|
||||
(!has(variables.pod.hostIPC) || variables.pod.hostIPC == false) &&
|
||||
(!has(variables.pod.shareProcessNamespace) || variables.pod.shareProcessNamespace == false) &&
|
||||
(!has(variables.pod.initContainers) || variables.pod.initContainers.size() == 0) &&
|
||||
(!has(variables.pod.ephemeralContainers) || variables.pod.ephemeralContainers.size() == 0) &&
|
||||
variables.pod.containers.size() == 1 &&
|
||||
variables.pod.volumes.size() == 3
|
||||
message: Secret action Job Pod authority is outside the reviewed contract
|
||||
- expression: >-
|
||||
variables.pod.securityContext.runAsNonRoot == true &&
|
||||
variables.pod.securityContext.runAsUser == 10001 &&
|
||||
variables.pod.securityContext.runAsGroup == 10001 &&
|
||||
variables.pod.securityContext.fsGroup == 10001 &&
|
||||
variables.pod.securityContext.seccompProfile.type == 'RuntimeDefault' &&
|
||||
variables.executor.name == 'executor' &&
|
||||
variables.executor.image == params.data.image &&
|
||||
variables.executor.imagePullPolicy == 'IfNotPresent' &&
|
||||
variables.executor.command == ['node', '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/executor/pluginPackageExecutorCli.js'] &&
|
||||
variables.executor.securityContext.allowPrivilegeEscalation == false &&
|
||||
variables.executor.securityContext.readOnlyRootFilesystem == true &&
|
||||
variables.executor.securityContext.capabilities.drop == ['ALL'] &&
|
||||
variables.executor.resources.requests.cpu == '25m' &&
|
||||
variables.executor.resources.requests.memory == '48Mi' &&
|
||||
variables.executor.resources.limits.cpu == '250m' &&
|
||||
variables.executor.resources.limits.memory == '192Mi'
|
||||
message: Secret action Job executor image, command or sandbox is outside the reviewed contract
|
||||
- expression: >-
|
||||
((params.data.postgresConnectionMode == 'url' && variables.executor.env.size() == 11) ||
|
||||
(params.data.postgresConnectionMode == 'fields' && variables.executor.env.size() == 15)) &&
|
||||
variables.executor.env.all(e, e.name in [
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED',
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER',
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID',
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT',
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS',
|
||||
'QL3_POSTGRES_TLS_MODE',
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
'QL3_POSTGRES_TLS_SERVERNAME',
|
||||
'QL3_POSTGRES_APPLICATION_NAME',
|
||||
'QL3_POSTGRES_MAX_CONNECTIONS',
|
||||
'QL3_POSTGRES_PACKAGE_EXECUTOR_URL',
|
||||
'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST',
|
||||
'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT',
|
||||
'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE',
|
||||
'QL3_POSTGRES_PACKAGE_EXECUTOR_USER',
|
||||
'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD'
|
||||
]) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED' && e.value == 'true') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER' && e.value.matches('^package_secret_[0-9a-f]{24}$')) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID' && e.value.matches('^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$')) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT' && e.value == '/var/run/secrets/qinglong3/plugin-package-values') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS' && e.value == '600000') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_TLS_MODE' && e.value == 'verify-full') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_TLS_CA_FILE' && e.value == '/var/run/secrets/qinglong3/postgres/ca.crt') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_TLS_SERVERNAME' && e.value == params.data.postgresServerName) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_APPLICATION_NAME' && e.value == 'qinglong3-package-secret-action') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_MAX_CONNECTIONS' && e.value == '1')
|
||||
message: Secret action Job environment is outside the reviewed contract
|
||||
- expression: >-
|
||||
(params.data.postgresConnectionMode == 'url' &&
|
||||
variables.executor.env.exists_one(e,
|
||||
e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL' &&
|
||||
e.valueFrom.secretKeyRef.name == params.data.postgresUrlSecretName &&
|
||||
e.valueFrom.secretKeyRef.key == params.data.postgresUrlSecretKey) &&
|
||||
!variables.executor.env.exists(e, e.name in ['QL3_POSTGRES_PACKAGE_EXECUTOR_HOST', 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT', 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE', 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER', 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD'])) ||
|
||||
(params.data.postgresConnectionMode == 'fields' &&
|
||||
!variables.executor.env.exists(e, e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL') &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST' && e.value == params.data.postgresHost) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT' && e.value == params.data.postgresPort) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE' && e.value == params.data.postgresDatabase) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER' && e.valueFrom.secretKeyRef.name == params.data.postgresAuthSecretName && e.valueFrom.secretKeyRef.key == params.data.postgresUsernameKey) &&
|
||||
variables.executor.env.exists_one(e, e.name == 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD' && e.valueFrom.secretKeyRef.name == params.data.postgresAuthSecretName && e.valueFrom.secretKeyRef.key == params.data.postgresPasswordKey))
|
||||
message: Secret action Job database authority is outside the reviewed contract
|
||||
- expression: >-
|
||||
variables.executor.volumeMounts.size() == 3 &&
|
||||
variables.executor.volumeMounts[0].name == 'tmp' &&
|
||||
variables.executor.volumeMounts[0].mountPath == '/tmp' &&
|
||||
(!has(variables.executor.volumeMounts[0].readOnly) || variables.executor.volumeMounts[0].readOnly == false) &&
|
||||
variables.executor.volumeMounts[1].name == 'postgres-ca' &&
|
||||
variables.executor.volumeMounts[1].mountPath == '/var/run/secrets/qinglong3/postgres' &&
|
||||
variables.executor.volumeMounts[1].readOnly == true &&
|
||||
variables.executor.volumeMounts[2].name == 'plugin-package-values' &&
|
||||
variables.executor.volumeMounts[2].mountPath == '/var/run/secrets/qinglong3/plugin-package-values' &&
|
||||
variables.executor.volumeMounts[2].readOnly == true &&
|
||||
variables.pod.volumes.exists_one(v, v.name == 'tmp' && v.emptyDir.medium == 'Memory' && v.emptyDir.sizeLimit == '8Mi') &&
|
||||
variables.ca.secret.secretName == params.data.postgresCaSecretName &&
|
||||
variables.ca.secret.optional == false &&
|
||||
variables.ca.secret.defaultMode == 292 &&
|
||||
variables.ca.secret.items.size() == 1 &&
|
||||
variables.ca.secret.items[0].key == params.data.postgresCaKey &&
|
||||
variables.ca.secret.items[0].path == 'ca.crt' &&
|
||||
((has(variables.values.secret) &&
|
||||
variables.values.secret.secretName == params.data.sourceSecretName &&
|
||||
variables.values.secret.optional == false &&
|
||||
variables.values.secret.defaultMode == 288 &&
|
||||
variables.values.secret.items.size() >= 1 &&
|
||||
variables.values.secret.items.size() <= 64 &&
|
||||
variables.values.secret.items.all(i, i.key.matches('^[0-9a-f]{64}$') && i.path == i.key) &&
|
||||
variables.values.secret.items.all(i, variables.values.secret.items.filter(j, j.key == i.key).size() == 1)) ||
|
||||
(has(variables.values.emptyDir) && variables.values.emptyDir.sizeLimit == '1Ki'))
|
||||
message: Secret action Job volumes are outside the exact-key projection contract
|
||||
+76
@@ -17,6 +17,82 @@
|
||||
value: '16'
|
||||
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES
|
||||
value: '16'
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED
|
||||
value: 'true'
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT
|
||||
value: '8'
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_NAMESPACE
|
||||
value: qinglong3-system
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_SERVICE_ACCOUNT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: serviceAccountName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_SOURCE_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: sourceSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: image
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresCaSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresCaKey
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresServerName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresUrlSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresUrlSecretKey
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_AUTH_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresAuthSecretName
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresHost
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresPort
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_DATABASE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresDatabase
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_USERNAME_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresUsernameKey
|
||||
- name: QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PASSWORD_KEY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
key: postgresPasswordKey
|
||||
- name: QL3_POSTGRES_TLS_MODE
|
||||
value: verify-full
|
||||
- name: QL3_POSTGRES_TLS_CA_FILE
|
||||
|
||||
+5
@@ -17,6 +17,11 @@ patches:
|
||||
kind: NetworkPolicy
|
||||
name: ql3-plugin-package-executor
|
||||
path: network-policy-patch.yaml
|
||||
- target:
|
||||
version: v1
|
||||
kind: ConfigMap
|
||||
name: ql3-plugin-package-secret-action-admission
|
||||
path: secret-action-admission-config-patch.yaml
|
||||
|
||||
images:
|
||||
- name: qinglong3-cluster-admin
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
- op: replace
|
||||
path: /data
|
||||
value:
|
||||
image: registry.example.com/qinglong/qinglong3-cluster-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
serviceAccountName: ql3-plugin-package-secret-action
|
||||
sourceSecretName: ql3-cluster-plugin-package-values
|
||||
postgresConnectionMode: fields
|
||||
postgresCaSecretName: ql3-postgres-ca
|
||||
postgresCaKey: ca.crt
|
||||
postgresServerName: ql3-postgres-rw.qinglong3-system.svc
|
||||
postgresUrlSecretName: ''
|
||||
postgresUrlSecretKey: ''
|
||||
postgresAuthSecretName: ql3-postgres-package-executor-auth
|
||||
postgresHost: ql3-postgres-rw.qinglong3-system.svc
|
||||
postgresPort: '5432'
|
||||
postgresDatabase: qinglong
|
||||
postgresUsernameKey: username
|
||||
postgresPasswordKey: password
|
||||
@@ -21,3 +21,7 @@ metadata:
|
||||
namespace: qinglong3-system
|
||||
type: Opaque
|
||||
data: {}
|
||||
|
||||
# The controller also needs one private-overlay NetworkPolicy patch containing
|
||||
# only the exact Kubernetes API endpoint CIDR and TCP port. Start from
|
||||
# api-server-egress-patch.example.yaml; do not permit arbitrary TCP/443 egress.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,3 +42,8 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首
|
||||
- Cluster management/executor 的 separation-of-duty 产品编排仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片把既有 package-manager Approval 与 package-executor 接入这条 durable authority,再进入 Kubernetes/低配现场门。
|
||||
- 后续切片已经完成 Cluster transition Approval/执行、Kubernetes active pointer v3 与三节点 rotation/revoke rollout 原语现场门;该历史未完成描述由本条取代。当前 executor 重构进一步增加 exact `dispatchById` start-barrier 路径和由 durable dispatch + approval plan 联合绑定的 action-scoped Job renderer。常规 batch CronJob 不再挂载 `ql3-cluster-plugin-package-values`,也不注册需要 Secret projection 的 handler;action Job 只投影单动作所需的去重 SHA-256 key,固定 `0440`、`optional:false`、digest 镜像、tokenless ServiceAccount、单连接 PostgreSQL 与 48 MiB request。零 Secret transition 使用空目录而不是空 Secret `items`。因此“整个 optional Secret 暴露给批处理 executor”的生产缺口已经失败关闭,但自动 controller 仍未启用:Kubernetes `jobs.create` 是可间接选择镜像/ServiceAccount/Secret 的放大权限,必须由 ValidatingAdmissionPolicy(或同等级外部 admission)把 renderer 契约固定后,才允许 create/get-only RBAC、响应丢失收敛与失败 Job 恢复。升级失败自动回滚、controller/admission/RBAC 真实门及固定物理低配证据继续阻断 ADR Accepted。
|
||||
- 本阶段完整性门已闭合:18-package clean build/test 退出 0;backend 1195 项为 1193 pass、2 条条件 skip、0 fail;package boundary 仍为 18 个 package 且无 single-source/shallow-source package,cluster dependency、edge import、cluster deployment 均零 finding。Edge arm64 本机观测模块加载 RSS `+8,945,664` bytes、1 万行输出峰值 `+5,226,496` bytes,不冒充固定物理低配门。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `45fab400eb449774d50429103dd766a2755166530ac54ddc1056f777bc16c15f`,临时 Docker 资源已清理。
|
||||
- 后续 controller/admission 切片取代上一条“自动 controller 仍未启用”的历史描述:生产 executor 已接入确定性 create/get-only Kubernetes Job controller。它以 durable approval/execution 为唯一创建依据;CREATE 的 409 或响应丢失通过 exact GET 收敛,`executing` 但 Job 缺失、approval 过期、终态 Job 或 contract 漂移均进入 `recoveryRequired`,不自动重建可能已经产生外部效果的动作。常规 dispatcher 只查询已注册 action type,因此不会误领取 Secret action。
|
||||
- Controller ServiceAccount 只具有 Job `create|get`,action ServiceAccount 无 API token且没有 Job/Pod/Secret 权限。`admissionregistration.k8s.io/v1` ValidatingAdmissionPolicy 以请求者身份和固定参数 ConfigMap 锁死 digest 镜像、command、ServiceAccount、source Secret、exact SHA-256 item/path、PostgreSQL SecretRef、安全上下文、资源与 volume/mount;`failurePolicy: Fail`、`parameterNotFoundAction: Deny`。基础 NetworkPolicy 保持 DNS-only,生产集群必须用私有 overlay 显式加入 API Server 精确 CIDR/TCP 443 出口,避免为 controller 放开任意公网。
|
||||
- 真实 K3s `v1.34.3+k3s1` 现场门已证明策略可由 API Server 编译:合规 Job dry-run 被接受,镜像漂移和参数 ConfigMap 缺失被拒绝;controller SA 的 `list|watch|delete jobs`、Pod 创建和 Secret 读取均被拒绝,action SA 的 Job/Pod 创建和 Secret 读取也均被拒绝。实现没有新增 workspace package、Edge daemon/timer/watcher 或低配设备常驻负担,18-package boundary 继续为 `singleSourcePackages=[]`、`shallowSourcePackages=[]`。
|
||||
- 本切片完整性门:controller/renderer/process 定向 18/18;cluster-admin 339 pass/3 条件 skip、cluster-postgres 328 pass/2 条件 skip;完整 18-package clean build/test 退出 0;backend 1196 项为 1194 pass、2 条条件 skip、0 fail;package boundary、cluster dependency、edge import、cluster deployment 均零 finding。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `a3d34e61ea2064e1cde574e533137186e09fdce9048455da64f582906037fa0d`,临时 Docker 资源已清理。
|
||||
- ADR 继续保持 Proposed:升级失败自动回滚、终态 Job 的 durable 恢复决议和固定物理低配设备证据尚未完成。当前 controller 明确暴露恢复要求,不以不安全的自动重试冒充闭环。
|
||||
|
||||
@@ -225,6 +225,11 @@
|
||||
"require": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.js",
|
||||
"default": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.js"
|
||||
},
|
||||
"./plugin-package-kubernetes-secret-action-controller": {
|
||||
"types": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionController.d.ts",
|
||||
"require": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionController.js",
|
||||
"default": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionController.js"
|
||||
},
|
||||
"./prompt-output-gc-process": {
|
||||
"types": "./dist/prompt-output/retention/promptOutputGcProcess.d.ts",
|
||||
"require": "./dist/prompt-output/retention/promptOutputGcProcess.js",
|
||||
|
||||
+268
-5
@@ -15,6 +15,9 @@ import {
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresApprovedActionExecutionRepository,
|
||||
PostgresPluginPackageSecretBindingApprovalPlanReader,
|
||||
PostgresPluginPackageSecretBindingTransitionApprovalPlanReader,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
@@ -48,6 +51,12 @@ import { ProjectedPluginPackageSecretExistenceInspector } from '../secret-bindin
|
||||
import {
|
||||
runClusterPluginPackagePublisherRevocation,
|
||||
} from '../publisher/pluginPackagePublisherRevocation';
|
||||
import {
|
||||
createClusterPluginPackageKubernetesSecretActionController,
|
||||
type ClusterPluginPackageKubernetesSecretActionControllerResource,
|
||||
type PluginPackageKubernetesSecretActionControllerSummary,
|
||||
} from './pluginPackageKubernetesSecretActionController';
|
||||
import type { PluginPackageKubernetesSecretActionJobOptions } from './pluginPackageKubernetesSecretActionJob';
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
@@ -66,6 +75,11 @@ export type ClusterPluginPackageExecutorProcessConfig =
|
||||
revocationMaxPages: number;
|
||||
dispatchId: string | null;
|
||||
secretProjectionRoot: string | null;
|
||||
kubernetesSecretActions: Readonly<{
|
||||
enabled: true;
|
||||
limit: number;
|
||||
job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
}> | null;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
@@ -78,6 +92,7 @@ export interface ClusterPluginPackageExecutorBatchResult {
|
||||
readonly secretBindingApprovals: Readonly<ClusterPluginPackageSecretBindingApprovalSummary>;
|
||||
readonly secretBindingTransitionApprovals: Readonly<ClusterPluginPackageSecretBindingTransitionApprovalSummary>;
|
||||
readonly dispatch: Readonly<ApprovedActionDispatchBatchSummary>;
|
||||
readonly secretActionJobs: Readonly<PluginPackageKubernetesSecretActionControllerSummary>;
|
||||
}
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessResult =
|
||||
@@ -116,6 +131,13 @@ export interface RunClusterPluginPackageExecutorProcessOptions {
|
||||
pool: PostgresPool,
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly now?: () => number;
|
||||
readonly createSecretActionController?: (
|
||||
options: Parameters<
|
||||
typeof createClusterPluginPackageKubernetesSecretActionController
|
||||
>[0],
|
||||
) => Promise<
|
||||
Readonly<ClusterPluginPackageKubernetesSecretActionControllerResource>
|
||||
>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
||||
@@ -131,6 +153,11 @@ export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
||||
|
||||
const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_DISPATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const DNS_NAME = /^(?=.{1,253}$)[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/;
|
||||
const SECRET_KEY = /^[A-Za-z0-9._-]{1,253}$/;
|
||||
const IMAGE_DIGEST =
|
||||
/^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$/;
|
||||
|
||||
function enabledValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
@@ -307,6 +334,179 @@ function secretProjectionRoot(
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredEnvironment(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string {
|
||||
const value = boundedValue(environment, name, maximumLength);
|
||||
if (value === undefined) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} is required when the Kubernetes Secret action controller is enabled`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function kubernetesSecretActions(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): Readonly<{
|
||||
enabled: true;
|
||||
limit: number;
|
||||
job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
}> | null {
|
||||
const enabled = environment.QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED;
|
||||
if (enabled === undefined || enabled === '' || enabled === 'false') return null;
|
||||
if (enabled !== 'true') {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED must be true or false',
|
||||
);
|
||||
}
|
||||
const namespace =
|
||||
boundedValue(environment, 'QL3_PLUGIN_PACKAGE_SECRET_ACTION_NAMESPACE', 63) ??
|
||||
'qinglong3-system';
|
||||
const serviceAccountName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_SERVICE_ACCOUNT',
|
||||
63,
|
||||
) ?? 'ql3-plugin-package-secret-action';
|
||||
const sourceSecretName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_SOURCE_SECRET',
|
||||
63,
|
||||
) ?? 'ql3-cluster-plugin-package-values';
|
||||
const image = requiredEnvironment(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE',
|
||||
640,
|
||||
);
|
||||
const postgresCaSecretName = requiredEnvironment(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET',
|
||||
63,
|
||||
);
|
||||
const postgresCaKey =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY',
|
||||
253,
|
||||
) ?? 'ca.crt';
|
||||
const servername = requiredEnvironment(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
const urlSecretName = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET',
|
||||
63,
|
||||
);
|
||||
const urlSecretKey = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY',
|
||||
253,
|
||||
);
|
||||
const fieldNames = [
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_AUTH_SECRET',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_HOST',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PORT',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_DATABASE',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_USERNAME_KEY',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PASSWORD_KEY',
|
||||
] as const;
|
||||
const fields = fieldNames.map((name) => boundedValue(environment, name, 253));
|
||||
const urlMode = urlSecretName !== undefined || urlSecretKey !== undefined;
|
||||
const fieldMode = fields.some((value) => value !== undefined);
|
||||
if (
|
||||
!DNS_LABEL.test(namespace) ||
|
||||
!DNS_LABEL.test(serviceAccountName) ||
|
||||
!DNS_LABEL.test(sourceSecretName) ||
|
||||
!IMAGE_DIGEST.test(image) ||
|
||||
!DNS_LABEL.test(postgresCaSecretName) ||
|
||||
!SECRET_KEY.test(postgresCaKey) ||
|
||||
!DNS_NAME.test(servername) ||
|
||||
urlMode === fieldMode
|
||||
) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action Job identity is invalid',
|
||||
);
|
||||
}
|
||||
let connection: PluginPackageKubernetesSecretActionJobOptions['postgres']['connection'];
|
||||
if (urlMode) {
|
||||
if (
|
||||
urlSecretName === undefined ||
|
||||
urlSecretKey === undefined ||
|
||||
!DNS_LABEL.test(urlSecretName) ||
|
||||
!SECRET_KEY.test(urlSecretKey)
|
||||
) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action PostgreSQL URL reference is invalid',
|
||||
);
|
||||
}
|
||||
connection = Object.freeze({
|
||||
mode: 'url',
|
||||
secretName: urlSecretName,
|
||||
urlKey: urlSecretKey,
|
||||
});
|
||||
} else {
|
||||
if (fields.some((value) => value === undefined)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action PostgreSQL field reference is incomplete',
|
||||
);
|
||||
}
|
||||
const [authSecretName, host, portValue, database, usernameKey, passwordKey] =
|
||||
fields as [string, string, string, string, string, string];
|
||||
const port = Number(portValue);
|
||||
if (
|
||||
!DNS_LABEL.test(authSecretName) ||
|
||||
!DNS_NAME.test(host) ||
|
||||
!Number.isSafeInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65_535 ||
|
||||
!/^[A-Za-z_][A-Za-z0-9_$-]{0,62}$/.test(database) ||
|
||||
!SECRET_KEY.test(usernameKey) ||
|
||||
!SECRET_KEY.test(passwordKey)
|
||||
) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action PostgreSQL field reference is invalid',
|
||||
);
|
||||
}
|
||||
connection = Object.freeze({
|
||||
mode: 'fields',
|
||||
authSecretName,
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
usernameKey,
|
||||
passwordKey,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
limit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT',
|
||||
8,
|
||||
1,
|
||||
32,
|
||||
),
|
||||
job: Object.freeze({
|
||||
namespace,
|
||||
serviceAccountName,
|
||||
sourceSecretName,
|
||||
image,
|
||||
postgres: Object.freeze({
|
||||
connection,
|
||||
caSecretName: postgresCaSecretName,
|
||||
caKey: postgresCaKey,
|
||||
servername,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageExecutorProcessConfig(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): ClusterPluginPackageExecutorProcessConfig {
|
||||
@@ -382,6 +582,7 @@ export function loadClusterPluginPackageExecutorProcessConfig(
|
||||
),
|
||||
dispatchId,
|
||||
secretProjectionRoot: secretProjectionRoot(environment),
|
||||
kubernetesSecretActions: kubernetesSecretActions(environment),
|
||||
database: databaseConfig(environment),
|
||||
});
|
||||
}
|
||||
@@ -424,6 +625,18 @@ function emptyApprovalSummary(): Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
function emptySecretActionJobSummary(): Readonly<PluginPackageKubernetesSecretActionControllerSummary> {
|
||||
return Object.freeze({
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageExecutorProcess(
|
||||
options: RunClusterPluginPackageExecutorProcessOptions,
|
||||
): Promise<ClusterPluginPackageExecutorProcessResult> {
|
||||
@@ -446,6 +659,8 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
typeof options.createDispatcher !== 'function') ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.createSecretActionController !== undefined &&
|
||||
typeof options.createSecretActionController !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Plugin Package executor process options are invalid');
|
||||
@@ -464,6 +679,9 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
let secretActionControllerResource:
|
||||
| Readonly<ClusterPluginPackageKubernetesSecretActionControllerResource>
|
||||
| undefined;
|
||||
try {
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresPackageExecutorSchemaReady
|
||||
@@ -531,6 +749,7 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
secretBindingApprovals: emptyApprovalSummary(),
|
||||
secretBindingTransitionApprovals: emptyApprovalSummary(),
|
||||
dispatch,
|
||||
secretActionJobs: emptySecretActionJobSummary(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
@@ -559,6 +778,32 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
limit: config.approvalBatchSize,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
let secretActionJobs = emptySecretActionJobSummary();
|
||||
if (config.kubernetesSecretActions !== null) {
|
||||
if (!secretActionControllerResource) {
|
||||
const createController =
|
||||
options.createSecretActionController ??
|
||||
createClusterPluginPackageKubernetesSecretActionController;
|
||||
secretActionControllerResource = await createController({
|
||||
executions: new PostgresApprovedActionExecutionRepository(
|
||||
database.pool,
|
||||
),
|
||||
bindingPlans:
|
||||
new PostgresPluginPackageSecretBindingApprovalPlanReader(
|
||||
database.pool,
|
||||
),
|
||||
transitionPlans:
|
||||
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(
|
||||
database.pool,
|
||||
),
|
||||
job: config.kubernetesSecretActions.job,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
}
|
||||
secretActionJobs = await secretActionControllerResource.controller.reconcile({
|
||||
limit: config.kubernetesSecretActions.limit,
|
||||
});
|
||||
}
|
||||
const dispatch = await dispatcher.dispatchBatch({
|
||||
limit: config.dispatchBatchSize,
|
||||
});
|
||||
@@ -568,9 +813,10 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
secretBindingApprovals,
|
||||
secretBindingTransitionApprovals,
|
||||
dispatch,
|
||||
secretActionJobs,
|
||||
});
|
||||
batches.push(batch);
|
||||
if (isIdleBatch(batch)) break;
|
||||
if (secretActionJobs.scanned > 0 || isIdleBatch(batch)) break;
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'completed',
|
||||
@@ -581,16 +827,33 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
let disposeError: unknown;
|
||||
try {
|
||||
secretActionControllerResource?.dispose();
|
||||
} catch (error) {
|
||||
disposeError = error;
|
||||
}
|
||||
let closeError: unknown;
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
const cleanupErrors = [disposeError, closeError].filter(
|
||||
(error) => error !== undefined,
|
||||
);
|
||||
if (cleanupErrors.length > 0) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Plugin Package executor process failed and PostgreSQL did not close',
|
||||
[failure, ...cleanupErrors],
|
||||
'Plugin Package executor process and resource cleanup failed',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
if (cleanupErrors.length === 1) throw cleanupErrors[0];
|
||||
throw new AggregateError(
|
||||
cleanupErrors,
|
||||
'Plugin Package executor resource cleanup failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
import type { ApprovedActionExecutionSnapshot } from '@qinglong/runtime-core/approved-action-execution';
|
||||
import {
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
||||
type PluginPackageSecretBindingApprovalPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
|
||||
import {
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
||||
type PluginPackageSecretBindingTransitionApprovalPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan';
|
||||
|
||||
import {
|
||||
createPluginPackageKubernetesSecretActionJob,
|
||||
type PluginPackageKubernetesSecretActionJobOptions,
|
||||
} from './pluginPackageKubernetesSecretActionJob';
|
||||
|
||||
const FIELD_MANAGER = 'qinglong-plugin-package-secret-action-controller';
|
||||
const MAX_PAGE_SIZE = 32;
|
||||
|
||||
type SecretActionApprovalPlan =
|
||||
| PluginPackageSecretBindingApprovalPlan
|
||||
| PluginPackageSecretBindingTransitionApprovalPlan;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface PluginPackageSecretActionApprovalPlanReader<T> {
|
||||
findByActionRef(actionRef: string): Promise<Readonly<T> | null>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionExecutionReader {
|
||||
listReconciliableExecutions(query: Readonly<{
|
||||
nowMs: number;
|
||||
limit: number;
|
||||
actionTypes: readonly string[];
|
||||
}>): Promise<Readonly<{
|
||||
executions: readonly Readonly<ApprovedActionExecutionSnapshot>[];
|
||||
truncated: boolean;
|
||||
}>>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionJobResource {
|
||||
readonly metadata?: Readonly<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
annotations?: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
readonly spec?: Readonly<Record<string, unknown>>;
|
||||
readonly status?: Readonly<{
|
||||
conditions?: readonly Readonly<{
|
||||
type?: string;
|
||||
status?: string;
|
||||
}>[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionJobApi {
|
||||
createNamespacedJob(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
body: Readonly<Record<string, unknown>>;
|
||||
fieldManager: typeof FIELD_MANAGER;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesSecretActionJobResource>;
|
||||
readNamespacedJob(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesSecretActionJobResource>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionControllerOptions {
|
||||
readonly executions: PluginPackageKubernetesSecretActionExecutionReader;
|
||||
readonly bindingPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingApprovalPlan>;
|
||||
readonly transitionPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingTransitionApprovalPlan>;
|
||||
readonly jobs: PluginPackageKubernetesSecretActionJobApi;
|
||||
readonly job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionControllerSummary {
|
||||
readonly scanned: number;
|
||||
readonly created: number;
|
||||
readonly existing: number;
|
||||
readonly active: number;
|
||||
readonly recoveryRequired: number;
|
||||
readonly unavailable: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageKubernetesSecretActionControllerResource {
|
||||
readonly controller: PluginPackageKubernetesSecretActionController;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class PluginPackageKubernetesSecretActionControllerConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_CONTROLLER_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Kubernetes Secret action Job conflicts with the durable dispatch');
|
||||
this.name = 'PluginPackageKubernetesSecretActionControllerConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageKubernetesSecretActionControllerUnavailableError extends Error {
|
||||
readonly code =
|
||||
'PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_CONTROLLER_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Kubernetes Secret action Job authority is unavailable', options);
|
||||
this.name =
|
||||
'PluginPackageKubernetesSecretActionControllerUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function apiStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
if ('code' in error && typeof error.code === 'number') return error.code;
|
||||
if (
|
||||
'response' in error &&
|
||||
error.response &&
|
||||
typeof error.response === 'object' &&
|
||||
'statusCode' in error.response &&
|
||||
typeof error.response.statusCode === 'number'
|
||||
) {
|
||||
return error.response.statusCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function object(value: unknown): JsonObject | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: null;
|
||||
}
|
||||
|
||||
function expectedSubset(expected: unknown, observed: unknown): boolean {
|
||||
if (Array.isArray(expected)) {
|
||||
return (
|
||||
Array.isArray(observed) &&
|
||||
expected.length === observed.length &&
|
||||
expected.every((value, index) => expectedSubset(value, observed[index]))
|
||||
);
|
||||
}
|
||||
const expectedObject = object(expected);
|
||||
if (expectedObject) {
|
||||
const observedObject = object(observed);
|
||||
return (
|
||||
observedObject !== null &&
|
||||
Object.entries(expectedObject).every(([key, value]) =>
|
||||
expectedSubset(value, observedObject[key]),
|
||||
)
|
||||
);
|
||||
}
|
||||
return Object.is(expected, observed);
|
||||
}
|
||||
|
||||
function terminalStatus(
|
||||
job: Readonly<PluginPackageKubernetesSecretActionJobResource>,
|
||||
): 'active' | 'complete' | 'failed' {
|
||||
const conditions = job.status?.conditions ?? [];
|
||||
const complete = conditions.some(
|
||||
(condition) => condition.type === 'Complete' && condition.status === 'True',
|
||||
);
|
||||
const failed = conditions.some(
|
||||
(condition) => condition.type === 'Failed' && condition.status === 'True',
|
||||
);
|
||||
if (complete && failed) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||
}
|
||||
if (complete) return 'complete';
|
||||
if (failed) return 'failed';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function assertObservedJob(
|
||||
expected: Readonly<Record<string, unknown>>,
|
||||
observed: Readonly<PluginPackageKubernetesSecretActionJobResource>,
|
||||
): void {
|
||||
const expectedMetadata = object(expected.metadata)!;
|
||||
const expectedSpec = object(expected.spec)!;
|
||||
const observedMetadata = observed.metadata;
|
||||
const observedPodSpec = object(
|
||||
object(object(observed.spec)?.template)?.spec,
|
||||
);
|
||||
if (
|
||||
observedMetadata?.name !== expectedMetadata.name ||
|
||||
observedMetadata?.namespace !== expectedMetadata.namespace ||
|
||||
!expectedSubset(expectedMetadata.labels, observedMetadata?.labels) ||
|
||||
!expectedSubset(expectedMetadata.annotations, observedMetadata?.annotations) ||
|
||||
!expectedSubset(expectedSpec, observed.spec) ||
|
||||
observedPodSpec === null ||
|
||||
observedPodSpec.hostNetwork === true ||
|
||||
observedPodSpec.hostPID === true ||
|
||||
observedPodSpec.hostIPC === true ||
|
||||
(Array.isArray(observedPodSpec.initContainers) &&
|
||||
observedPodSpec.initContainers.length > 0) ||
|
||||
(Array.isArray(observedPodSpec.ephemeralContainers) &&
|
||||
observedPodSpec.ephemeralContainers.length > 0)
|
||||
) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageKubernetesSecretActionController {
|
||||
readonly #now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly options: PluginPackageKubernetesSecretActionControllerOptions,
|
||||
) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
!options.executions ||
|
||||
typeof options.executions.listReconciliableExecutions !== 'function' ||
|
||||
!options.bindingPlans ||
|
||||
typeof options.bindingPlans.findByActionRef !== 'function' ||
|
||||
!options.transitionPlans ||
|
||||
typeof options.transitionPlans.findByActionRef !== 'function' ||
|
||||
!options.jobs ||
|
||||
typeof options.jobs.createNamespacedJob !== 'function' ||
|
||||
typeof options.jobs.readNamespacedJob !== 'function' ||
|
||||
!options.job ||
|
||||
typeof options.job !== 'object' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Kubernetes Secret action controller options are invalid');
|
||||
}
|
||||
this.#now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
input: Readonly<{ limit?: number }> = {},
|
||||
): Promise<Readonly<PluginPackageKubernetesSecretActionControllerSummary>> {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
Object.keys(input).some((key) => key !== 'limit')
|
||||
) {
|
||||
throw new TypeError('Kubernetes Secret action reconciliation is invalid');
|
||||
}
|
||||
const limit = input.limit ?? 8;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) {
|
||||
throw new RangeError('Kubernetes Secret action reconciliation limit is invalid');
|
||||
}
|
||||
const nowMs = this.#now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('Kubernetes Secret action controller clock is invalid');
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = await this.options.executions.listReconciliableExecutions({
|
||||
nowMs,
|
||||
limit,
|
||||
actionTypes: Object.freeze([
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
||||
]),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
const summary = {
|
||||
scanned: page.executions.length,
|
||||
created: 0,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
for (const snapshot of page.executions) {
|
||||
try {
|
||||
const result = await this.#reconcileOne(snapshot, nowMs);
|
||||
summary[result] += 1;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageKubernetesSecretActionControllerConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
summary.unavailable += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...summary });
|
||||
}
|
||||
|
||||
async #reconcileOne(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
nowMs: number,
|
||||
): Promise<'created' | 'existing' | 'active' | 'recoveryRequired'> {
|
||||
const plan = await this.#plan(snapshot);
|
||||
if (!plan) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerUnavailableError();
|
||||
}
|
||||
const desired = createPluginPackageKubernetesSecretActionJob({
|
||||
dispatch: snapshot.dispatch,
|
||||
approvalPlan: plan,
|
||||
options: this.options.job,
|
||||
});
|
||||
const metadata = object(desired.metadata)!;
|
||||
const name = metadata.name as string;
|
||||
const namespace = metadata.namespace as string;
|
||||
let observed: PluginPackageKubernetesSecretActionJobResource;
|
||||
let disposition: 'created' | 'existing' | 'active' = 'active';
|
||||
try {
|
||||
observed = await this.options.jobs.readNamespacedJob({ name, namespace });
|
||||
} catch (error) {
|
||||
if (apiStatus(error) !== 404) throw error;
|
||||
if (
|
||||
snapshot.execution.status === 'executing' ||
|
||||
nowMs > plan.expiresAtMs
|
||||
) {
|
||||
return 'recoveryRequired';
|
||||
}
|
||||
try {
|
||||
observed = await this.options.jobs.createNamespacedJob({
|
||||
namespace,
|
||||
body: desired,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
});
|
||||
disposition = 'created';
|
||||
} catch (createError) {
|
||||
// CREATE may have succeeded even when its response was lost. Converge
|
||||
// every ambiguous failure through an exact-name GET before surfacing it.
|
||||
try {
|
||||
observed = await this.options.jobs.readNamespacedJob({
|
||||
name,
|
||||
namespace,
|
||||
});
|
||||
} catch (readAfterCreateError) {
|
||||
if (apiStatus(readAfterCreateError) === 404) throw createError;
|
||||
throw readAfterCreateError;
|
||||
}
|
||||
disposition = 'existing';
|
||||
}
|
||||
}
|
||||
assertObservedJob(desired, observed);
|
||||
const terminal = terminalStatus(observed);
|
||||
if (terminal !== 'active') return 'recoveryRequired';
|
||||
return disposition;
|
||||
}
|
||||
|
||||
#plan(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
): Promise<Readonly<SecretActionApprovalPlan> | null> {
|
||||
const { actionType, actionRef } = snapshot.dispatch.action;
|
||||
if (actionType === PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE) {
|
||||
return this.options.bindingPlans.findByActionRef(actionRef);
|
||||
}
|
||||
if (actionType === PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE) {
|
||||
return this.options.transitionPlans.findByActionRef(actionRef);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type KubernetesModule = typeof import('@kubernetes/client-node', {
|
||||
with: { 'resolution-mode': 'import' }
|
||||
});
|
||||
|
||||
export async function createClusterPluginPackageKubernetesSecretActionController(
|
||||
options: Omit<
|
||||
PluginPackageKubernetesSecretActionControllerOptions,
|
||||
'jobs'
|
||||
>,
|
||||
): Promise<Readonly<ClusterPluginPackageKubernetesSecretActionControllerResource>> {
|
||||
const kubernetes = (await import('@kubernetes/client-node')) as KubernetesModule;
|
||||
const config = new kubernetes.KubeConfig();
|
||||
config.loadFromCluster();
|
||||
const jobs = config.makeApiClient(
|
||||
kubernetes.BatchV1Api,
|
||||
) as unknown as PluginPackageKubernetesSecretActionJobApi;
|
||||
let active = true;
|
||||
return Object.freeze({
|
||||
controller: new PluginPackageKubernetesSecretActionController({
|
||||
...options,
|
||||
jobs,
|
||||
}),
|
||||
dispose() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
for (const user of config.getUsers()) {
|
||||
const mutable = user as {
|
||||
token?: string;
|
||||
certData?: string;
|
||||
keyData?: string;
|
||||
};
|
||||
mutable.token = '';
|
||||
mutable.certData = '';
|
||||
mutable.keyData = '';
|
||||
}
|
||||
config.setCurrentContext('disposed');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,27 @@ function environment(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function actionControllerEnvironment(overrides = {}) {
|
||||
return environment({
|
||||
QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: undefined,
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED: 'true',
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT: '6',
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE:
|
||||
'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' +
|
||||
'c'.repeat(64),
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET:
|
||||
'ql3-cluster-plugin-package-executor',
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY: 'postgres-ca.crt',
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME:
|
||||
'postgres.qinglong3-system.svc',
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET:
|
||||
'ql3-cluster-plugin-package-executor',
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY:
|
||||
'postgres-package-executor-url',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('disabled executor opens no PostgreSQL authority', async () => {
|
||||
let opened = 0;
|
||||
const result = await runClusterPluginPackageExecutorProcess({
|
||||
@@ -74,6 +95,27 @@ test('loads one bounded action-scoped dispatch without widening batch limits', (
|
||||
assert.equal(config.maxBatches, 4);
|
||||
});
|
||||
|
||||
test('loads a bounded digest-pinned Kubernetes Secret action controller', () => {
|
||||
const config = loadClusterPluginPackageExecutorProcessConfig(
|
||||
actionControllerEnvironment(),
|
||||
);
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.secretProjectionRoot, null);
|
||||
assert.equal(config.kubernetesSecretActions.limit, 6);
|
||||
assert.equal(
|
||||
config.kubernetesSecretActions.job.serviceAccountName,
|
||||
'ql3-plugin-package-secret-action',
|
||||
);
|
||||
assert.equal(
|
||||
config.kubernetesSecretActions.job.postgres.connection.mode,
|
||||
'url',
|
||||
);
|
||||
assert.equal(
|
||||
config.kubernetesSecretActions.job.postgres.connection.secretName,
|
||||
'ql3-cluster-plugin-package-executor',
|
||||
);
|
||||
});
|
||||
|
||||
test('action-scoped mode skips every Approval consumer and shared queue scan', async () => {
|
||||
const calls = [];
|
||||
const pool = {};
|
||||
@@ -151,6 +193,129 @@ test('action-scoped mode skips every Approval consumer and shared queue scan', a
|
||||
assert.equal(result.batches[0].dispatch.succeeded, 1);
|
||||
});
|
||||
|
||||
test('batch mode consumes approvals before reconciling exact Secret action Jobs', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query() {
|
||||
throw new Error('repositories are injected behind the controller factory');
|
||||
},
|
||||
async connect() {
|
||||
throw new Error('repositories are injected behind the controller factory');
|
||||
},
|
||||
};
|
||||
const approvalSummary = {
|
||||
scanned: 0,
|
||||
consumed: 0,
|
||||
existing: 0,
|
||||
expired: 0,
|
||||
blocked: 0,
|
||||
};
|
||||
const readiness = {
|
||||
ready: true,
|
||||
writablePrimary: true,
|
||||
serverVersionNum: 180004,
|
||||
serverMajor: 18,
|
||||
currentUser: 'ql3_package_executor',
|
||||
contractName: 'control-core',
|
||||
contractVersion: 62,
|
||||
migrationIds: ['pg-0063-plugin-package-secret-binding-transition-receipts'],
|
||||
};
|
||||
const result = await runClusterPluginPackageExecutorProcess({
|
||||
environment: actionControllerEnvironment(),
|
||||
async openDatabase() {
|
||||
calls.push('open');
|
||||
return {
|
||||
pool,
|
||||
async close() {
|
||||
calls.push('close');
|
||||
},
|
||||
};
|
||||
},
|
||||
async assertReady() {
|
||||
calls.push('ready');
|
||||
return readiness;
|
||||
},
|
||||
async consumeApprovals() {
|
||||
calls.push('publisher-approvals');
|
||||
return approvalSummary;
|
||||
},
|
||||
async consumeTrustTransitionApprovals() {
|
||||
calls.push('trust-approvals');
|
||||
return approvalSummary;
|
||||
},
|
||||
async consumeSecretBindingApprovals() {
|
||||
calls.push('binding-approvals');
|
||||
return { ...approvalSummary, scanned: 1, consumed: 1 };
|
||||
},
|
||||
async consumeSecretBindingTransitionApprovals() {
|
||||
calls.push('transition-approvals');
|
||||
return approvalSummary;
|
||||
},
|
||||
async createSecretActionController(options) {
|
||||
assert.equal(options.job.image.endsWith('c'.repeat(64)), true);
|
||||
calls.push('controller-open');
|
||||
return {
|
||||
controller: {
|
||||
async reconcile({ limit }) {
|
||||
calls.push(`reconcile:${limit}`);
|
||||
return {
|
||||
scanned: 1,
|
||||
created: 1,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
dispose() {
|
||||
calls.push('controller-close');
|
||||
},
|
||||
};
|
||||
},
|
||||
createDispatcher() {
|
||||
return {
|
||||
async dispatchById() {
|
||||
throw new Error('batch mode must not exact dispatch');
|
||||
},
|
||||
async dispatchBatch() {
|
||||
calls.push('dispatch-batch');
|
||||
return {
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
started: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(result.batches.length, 1);
|
||||
assert.equal(result.batches[0].secretActionJobs.created, 1);
|
||||
assert.deepEqual(calls, [
|
||||
'open',
|
||||
'ready',
|
||||
'publisher-approvals',
|
||||
'trust-approvals',
|
||||
'binding-approvals',
|
||||
'transition-approvals',
|
||||
'controller-open',
|
||||
'reconcile:6',
|
||||
'dispatch-batch',
|
||||
'controller-close',
|
||||
'close',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects implicit insecure PostgreSQL and unbounded work', () => {
|
||||
for (const invalid of [
|
||||
environment({ QL3_POSTGRES_ALLOW_INSECURE: undefined }),
|
||||
@@ -161,6 +326,12 @@ test('rejects implicit insecure PostgreSQL and unbounded work', () => {
|
||||
environment({
|
||||
QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch id with spaces',
|
||||
}),
|
||||
actionControllerEnvironment({
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE: 'tag-only:latest',
|
||||
}),
|
||||
actionControllerEnvironment({
|
||||
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET: undefined,
|
||||
}),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => loadClusterPluginPackageExecutorProcessConfig(invalid),
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
createPluginPackageSecretBindingApprovalPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan');
|
||||
const {
|
||||
createPluginPackageSecretBindingPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
||||
const {
|
||||
createSecretRef,
|
||||
} = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
PluginPackageKubernetesSecretActionController,
|
||||
PluginPackageKubernetesSecretActionControllerConflictError,
|
||||
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-controller');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' });
|
||||
const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' });
|
||||
const CONSUMER = Object.freeze({
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
});
|
||||
const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 });
|
||||
|
||||
function fixture() {
|
||||
const manifest = {
|
||||
apiVersion: 'qinglong.io/v1alpha1',
|
||||
kind: 'Package',
|
||||
metadata: {
|
||||
name: 'controller-fixture',
|
||||
displayName: 'Controller Fixture',
|
||||
version: '1.0.0',
|
||||
description: 'Secret action controller fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['cluster-control'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [{ name: 'TOKEN', required: true }],
|
||||
tools: ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const generation = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-controller-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'controller-fixture',
|
||||
lockDigest: 'a'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
contents: manifest.spec.contents,
|
||||
});
|
||||
const bindingPlan = createPluginPackageSecretBindingPlan({
|
||||
generation,
|
||||
manifest,
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
plannedAtMs: 100,
|
||||
});
|
||||
const plan = createPluginPackageSecretBindingApprovalPlan({
|
||||
actionRef: 'secret-binding:controller-fixture',
|
||||
bindingPlan,
|
||||
requestedBy: REQUESTER,
|
||||
expiresAtMs: 1_000,
|
||||
});
|
||||
const action = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan')
|
||||
.pluginPackageSecretBindingApprovedAction(plan);
|
||||
const approved = decideApprovalRequest(
|
||||
createApprovalRequest({
|
||||
id: 'approval-controller-1',
|
||||
projectId: 'project-1',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 110,
|
||||
expiresAtMs: 900,
|
||||
requestFence: FENCE,
|
||||
}),
|
||||
{
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-controller-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REVIEWER,
|
||||
authenticationId: 'auth-reviewer',
|
||||
authenticatedAtMs: 100,
|
||||
expiresAtMs: 800,
|
||||
assurance: 'multi_factor',
|
||||
},
|
||||
decidedAtMs: 120,
|
||||
authorizationFence: FENCE,
|
||||
},
|
||||
);
|
||||
const dispatch = consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-controller-1',
|
||||
dispatchId: 'dispatch-controller-1',
|
||||
action,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: CONSUMER,
|
||||
consumedAtMs: 130,
|
||||
authorizationFence: FENCE,
|
||||
}).dispatch;
|
||||
return {
|
||||
plan,
|
||||
snapshot: Object.freeze({
|
||||
dispatch,
|
||||
execution: createApprovedActionExecution(dispatch),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function jobOptions() {
|
||||
return {
|
||||
namespace: 'qinglong3-system',
|
||||
serviceAccountName: 'ql3-plugin-package-secret-action',
|
||||
sourceSecretName: 'ql3-cluster-plugin-package-values',
|
||||
image:
|
||||
'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' +
|
||||
'c'.repeat(64),
|
||||
postgres: {
|
||||
connection: {
|
||||
mode: 'url',
|
||||
secretName: 'ql3-cluster-plugin-package-executor',
|
||||
urlKey: 'postgres-package-executor-url',
|
||||
},
|
||||
caSecretName: 'ql3-cluster-plugin-package-executor',
|
||||
caKey: 'postgres-ca.crt',
|
||||
servername: 'postgres.qinglong3-system.svc',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function apiError(code) {
|
||||
return Object.assign(new Error(`Kubernetes ${code}`), { code });
|
||||
}
|
||||
|
||||
function controller({ read, create, now = 200, snapshot: snapshotOverride }) {
|
||||
const { plan, snapshot } = fixture();
|
||||
return new PluginPackageKubernetesSecretActionController({
|
||||
executions: {
|
||||
async listReconciliableExecutions(query) {
|
||||
assert.deepEqual(query.actionTypes, [
|
||||
'plugin_package.secret_binding.bind',
|
||||
'plugin_package.secret_binding.transition',
|
||||
]);
|
||||
return {
|
||||
executions: [snapshotOverride ?? snapshot],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
bindingPlans: {
|
||||
async findByActionRef(actionRef) {
|
||||
assert.equal(actionRef, plan.actionRef);
|
||||
return plan;
|
||||
},
|
||||
},
|
||||
transitionPlans: {
|
||||
async findByActionRef() {
|
||||
throw new Error('transition reader must not run');
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
readNamespacedJob: read,
|
||||
createNamespacedJob: create,
|
||||
},
|
||||
job: jobOptions(),
|
||||
now: () => now,
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one Strict deterministic Job without claiming the execution', async () => {
|
||||
const calls = [];
|
||||
const subject = controller({
|
||||
async read(request) {
|
||||
calls.push(['read', request]);
|
||||
throw apiError(404);
|
||||
},
|
||||
async create(request) {
|
||||
calls.push(['create', request]);
|
||||
return request.body;
|
||||
},
|
||||
});
|
||||
const result = await subject.reconcile({ limit: 4 });
|
||||
assert.equal(result.created, 1);
|
||||
assert.equal(result.recoveryRequired, 0);
|
||||
assert.equal(calls[0][0], 'read');
|
||||
assert.equal(calls[1][0], 'create');
|
||||
assert.equal(calls[1][1].fieldValidation, 'Strict');
|
||||
assert.equal(
|
||||
calls[1][1].fieldManager,
|
||||
'qinglong-plugin-package-secret-action-controller',
|
||||
);
|
||||
assert.match(calls[1][1].body.metadata.name, /^ql3-package-secret-[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
test('converges a concurrent create through one exact get', async () => {
|
||||
let desired;
|
||||
let reads = 0;
|
||||
const subject = controller({
|
||||
async read() {
|
||||
reads += 1;
|
||||
if (reads === 1) throw apiError(404);
|
||||
return {
|
||||
...desired,
|
||||
metadata: {
|
||||
...desired.metadata,
|
||||
uid: 'server-owned-uid',
|
||||
},
|
||||
spec: {
|
||||
...desired.spec,
|
||||
completionMode: 'NonIndexed',
|
||||
},
|
||||
};
|
||||
},
|
||||
async create(request) {
|
||||
desired = request.body;
|
||||
throw apiError(409);
|
||||
},
|
||||
});
|
||||
const result = await subject.reconcile();
|
||||
assert.equal(result.existing, 1);
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
|
||||
test('converges a lost successful CREATE response through one exact get', async () => {
|
||||
let desired;
|
||||
let reads = 0;
|
||||
const subject = controller({
|
||||
async read() {
|
||||
reads += 1;
|
||||
if (reads === 1) throw apiError(404);
|
||||
return desired;
|
||||
},
|
||||
async create(request) {
|
||||
desired = request.body;
|
||||
throw apiError(503);
|
||||
},
|
||||
});
|
||||
const result = await subject.reconcile();
|
||||
assert.equal(result.existing, 1);
|
||||
assert.equal(result.unavailable, 0);
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
|
||||
test('does not recreate a missing Job for an already executing action', async () => {
|
||||
const { snapshot } = fixture();
|
||||
let creates = 0;
|
||||
const subject = controller({
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
execution: { ...snapshot.execution, status: 'executing' },
|
||||
},
|
||||
async read() {
|
||||
throw apiError(404);
|
||||
},
|
||||
async create() {
|
||||
creates += 1;
|
||||
throw new Error('must not create');
|
||||
},
|
||||
});
|
||||
const result = await subject.reconcile();
|
||||
assert.equal(result.recoveryRequired, 1);
|
||||
assert.equal(creates, 0);
|
||||
});
|
||||
|
||||
test('does not create a missing Job after the approval plan expires', async () => {
|
||||
let creates = 0;
|
||||
const subject = controller({
|
||||
now: 1_001,
|
||||
async read() {
|
||||
throw apiError(404);
|
||||
},
|
||||
async create() {
|
||||
creates += 1;
|
||||
throw new Error('must not create');
|
||||
},
|
||||
});
|
||||
const result = await subject.reconcile();
|
||||
assert.equal(result.recoveryRequired, 1);
|
||||
assert.equal(creates, 0);
|
||||
});
|
||||
|
||||
test('marks a terminal Job with a nonterminal execution as recovery required', async () => {
|
||||
let desired;
|
||||
const subject = controller({
|
||||
async read() {
|
||||
if (!desired) throw apiError(404);
|
||||
return desired;
|
||||
},
|
||||
async create(request) {
|
||||
desired = {
|
||||
...request.body,
|
||||
status: {
|
||||
conditions: [{ type: 'Failed', status: 'True' }],
|
||||
},
|
||||
};
|
||||
return desired;
|
||||
},
|
||||
});
|
||||
const result = await subject.reconcile();
|
||||
assert.equal(result.recoveryRequired, 1);
|
||||
assert.equal(result.created, 0);
|
||||
});
|
||||
|
||||
test('fails closed when a deterministic Job name contains another contract', async () => {
|
||||
const subject = controller({
|
||||
async read() {
|
||||
const error = apiError(404);
|
||||
throw error;
|
||||
},
|
||||
async create(request) {
|
||||
return {
|
||||
...request.body,
|
||||
metadata: {
|
||||
...request.body.metadata,
|
||||
annotations: {
|
||||
...request.body.metadata.annotations,
|
||||
'qinglong.io/secret-action-job-digest': 'f'.repeat(64),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => subject.reconcile(),
|
||||
PluginPackageKubernetesSecretActionControllerConflictError,
|
||||
);
|
||||
});
|
||||
+56
@@ -379,6 +379,62 @@ export class PostgresApprovedActionExecutionRepository
|
||||
}
|
||||
}
|
||||
|
||||
async listReconciliableExecutions(
|
||||
query: Readonly<{
|
||||
nowMs: number;
|
||||
limit: number;
|
||||
actionTypes: readonly string[];
|
||||
}>,
|
||||
): Promise<Readonly<{
|
||||
executions: readonly Readonly<ApprovedActionExecutionSnapshot>[];
|
||||
truncated: boolean;
|
||||
}>> {
|
||||
assertPageSize(query.limit);
|
||||
const handledActionTypes = actionTypes(query.actionTypes);
|
||||
if (!Number.isSafeInteger(query.nowMs) || query.nowMs < 0) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
if (handledActionTypes.length === 0) {
|
||||
return Object.freeze({ executions: Object.freeze([]), truncated: false });
|
||||
}
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT execution.execution_json AS "executionJson",
|
||||
execution.execution_digest AS "executionDigest",
|
||||
dispatch.dispatch_json AS "dispatchJson"
|
||||
FROM "ql3"."approved_action_executions" AS execution
|
||||
JOIN "ql3"."approved_action_dispatches" AS dispatch
|
||||
ON dispatch.dispatch_id = execution.dispatch_id
|
||||
WHERE dispatch.action_type = ANY($1::varchar[])
|
||||
AND (
|
||||
(
|
||||
execution.status IN ('pending','leased','retry_wait')
|
||||
AND execution.eligible_at_ms <= $2
|
||||
)
|
||||
OR execution.status = 'executing'
|
||||
)
|
||||
ORDER BY execution.updated_at_ms, execution.dispatch_id
|
||||
LIMIT $3`,
|
||||
[handledActionTypes, query.nowMs, query.limit + 1],
|
||||
);
|
||||
const truncated = result.rows.length > query.limit;
|
||||
const rows = truncated ? result.rows.slice(0, query.limit) : result.rows;
|
||||
return Object.freeze({
|
||||
executions: Object.freeze(
|
||||
rows.map((row) =>
|
||||
normalizeApprovedActionExecutionSnapshot({
|
||||
dispatch: parseDispatch(row),
|
||||
execution: parseExecution(row),
|
||||
}),
|
||||
),
|
||||
),
|
||||
truncated,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
claimExecution(
|
||||
command: ClaimApprovedActionExecutionCommand,
|
||||
): Promise<ClaimApprovedActionExecutionResult> {
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
|
||||
export { PostgresPluginPackageMaterializedRevisionRepository } from '../plugin-package/installation/pluginPackageMaterializedRevisionRepository';
|
||||
export { PostgresApprovalRequestRepository } from '../approved-action/approvalRequestRepository';
|
||||
export { PostgresApprovedActionExecutionRepository } from '../approved-action/approvedActionExecutionRepository';
|
||||
export { PostgresProjectPolicyRepository } from '../security/projectPolicyRepository';
|
||||
export { PostgresPluginPackageSecretBindingRepository } from '../plugin-package/installation/pluginPackageSecretBindingRepository';
|
||||
export { PostgresPluginPackageSecretBindingActivationPrerequisite } from '../plugin-package/secret-binding/pluginPackageSecretBindingActivationPrerequisite';
|
||||
|
||||
@@ -367,11 +367,16 @@ test('Approved Action execution authority is isolated behind its explicit subpat
|
||||
const runtime = require('@qinglong/cluster-postgres/runtime');
|
||||
const admin = require('@qinglong/cluster-postgres/admin');
|
||||
const ingress = require('@qinglong/cluster-postgres/worker-ingress');
|
||||
const packageExecutor = require('@qinglong/cluster-postgres/package-executor');
|
||||
const authority = require('@qinglong/cluster-postgres/approved-action-execution');
|
||||
assert.equal(root.PostgresApprovedActionExecutionRepository, undefined);
|
||||
assert.equal(runtime.PostgresApprovedActionExecutionRepository, undefined);
|
||||
assert.equal(admin.PostgresApprovedActionExecutionRepository, undefined);
|
||||
assert.equal(ingress.PostgresApprovedActionExecutionRepository, undefined);
|
||||
assert.equal(
|
||||
packageExecutor.PostgresApprovedActionExecutionRepository,
|
||||
authority.PostgresApprovedActionExecutionRepository,
|
||||
);
|
||||
assert.equal(
|
||||
typeof authority.PostgresApprovedActionExecutionRepository,
|
||||
'function',
|
||||
|
||||
@@ -3581,6 +3581,22 @@ if (!migrationConnectionString) {
|
||||
const executions = new PostgresApprovedActionExecutionRepository(
|
||||
executorDatabase.pool,
|
||||
);
|
||||
const pendingSecretActions =
|
||||
await executions.listReconciliableExecutions({
|
||||
nowMs: claimedAtMs,
|
||||
limit: 1,
|
||||
actionTypes: [consumed.dispatch.action.actionType],
|
||||
});
|
||||
assert.equal(pendingSecretActions.truncated, false);
|
||||
assert.equal(pendingSecretActions.executions.length, 1);
|
||||
assert.equal(
|
||||
pendingSecretActions.executions[0].dispatch.id,
|
||||
consumed.dispatch.id,
|
||||
);
|
||||
assert.equal(
|
||||
pendingSecretActions.executions[0].execution.status,
|
||||
'pending',
|
||||
);
|
||||
const claimed = await executions.claimExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
owner: 'package_admission_dispatcher',
|
||||
@@ -3598,6 +3614,22 @@ if (!migrationConnectionString) {
|
||||
expectedVersion: claimed.snapshot.execution.version,
|
||||
startedAtMs: admittedAtMs,
|
||||
});
|
||||
const executingSecretActions =
|
||||
await executions.listReconciliableExecutions({
|
||||
nowMs: admittedAtMs,
|
||||
limit: 1,
|
||||
actionTypes: [consumed.dispatch.action.actionType],
|
||||
});
|
||||
assert.equal(executingSecretActions.truncated, false);
|
||||
assert.equal(executingSecretActions.executions.length, 1);
|
||||
assert.equal(
|
||||
executingSecretActions.executions[0].dispatch.id,
|
||||
consumed.dispatch.id,
|
||||
);
|
||||
assert.equal(
|
||||
executingSecretActions.executions[0].execution.status,
|
||||
'executing',
|
||||
);
|
||||
const lock = resolvePluginPackageInstallProposal(
|
||||
proposal,
|
||||
consumed.dispatch,
|
||||
|
||||
@@ -3185,6 +3185,12 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
const base = path.join(operationRoot, 'base');
|
||||
const expectedResources = [
|
||||
'service-account.yaml',
|
||||
'secret-action-service-account.yaml',
|
||||
'secret-action-admission-config.yaml',
|
||||
'role.yaml',
|
||||
'role-binding.yaml',
|
||||
'validating-admission-policy.yaml',
|
||||
'validating-admission-policy-binding.yaml',
|
||||
'cron-job.yaml',
|
||||
'network-policy.yaml',
|
||||
];
|
||||
@@ -3196,6 +3202,25 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
);
|
||||
const name = 'ql3-plugin-package-executor';
|
||||
const serviceAccount = namedResource(resources, 'ServiceAccount', name);
|
||||
const actionName = 'ql3-plugin-package-secret-action';
|
||||
const actionServiceAccount = namedResource(
|
||||
resources,
|
||||
'ServiceAccount',
|
||||
actionName,
|
||||
);
|
||||
const admissionConfig = namedResource(resources, 'ConfigMap', actionName + '-admission');
|
||||
const role = namedResource(resources, 'Role', name);
|
||||
const roleBinding = namedResource(resources, 'RoleBinding', name);
|
||||
const admissionPolicy = namedResource(
|
||||
resources,
|
||||
'ValidatingAdmissionPolicy',
|
||||
actionName,
|
||||
);
|
||||
const admissionBinding = namedResource(
|
||||
resources,
|
||||
'ValidatingAdmissionPolicyBinding',
|
||||
actionName,
|
||||
);
|
||||
const cronJob = namedResource(resources, 'CronJob', name);
|
||||
const networkPolicy = namedResource(resources, 'NetworkPolicy', name);
|
||||
const pod = objectAt(cronJob, [
|
||||
@@ -3211,10 +3236,11 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
JSON.stringify(kustomization?.resources) !==
|
||||
JSON.stringify(expectedResources) ||
|
||||
serviceAccount?.automountServiceAccountToken !== false ||
|
||||
actionServiceAccount?.automountServiceAccountToken !== false ||
|
||||
cronJob?.spec?.schedule !== '*/2 * * * *' ||
|
||||
cronJob?.spec?.concurrencyPolicy !== 'Forbid' ||
|
||||
pod?.serviceAccountName !== name ||
|
||||
pod?.automountServiceAccountToken !== false ||
|
||||
pod?.automountServiceAccountToken !== true ||
|
||||
pod?.restartPolicy !== 'Never' ||
|
||||
pod?.securityContext?.runAsNonRoot !== true ||
|
||||
pod?.securityContext?.seccompProfile?.type !== 'RuntimeDefault' ||
|
||||
@@ -3227,7 +3253,71 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_PLUGIN_EXECUTOR_LIFECYCLE',
|
||||
'Plugin Package executor must remain a bounded, tokenless, non-overlapping CronJob',
|
||||
'Plugin Package executor must remain bounded and non-overlapping, with Kubernetes authority isolated from tokenless Secret action Jobs',
|
||||
),
|
||||
);
|
||||
}
|
||||
const expectedRoleRules = [
|
||||
{
|
||||
apiGroups: ['batch'],
|
||||
resources: ['jobs'],
|
||||
verbs: ['create', 'get'],
|
||||
},
|
||||
{
|
||||
apiGroups: [''],
|
||||
resources: ['configmaps'],
|
||||
resourceNames: ['ql3-plugin-package-secret-action-admission'],
|
||||
verbs: ['get'],
|
||||
},
|
||||
];
|
||||
const admissionExpressions = (admissionPolicy?.spec?.validations ?? [])
|
||||
.map((validation) => validation?.expression)
|
||||
.join('\n');
|
||||
if (
|
||||
JSON.stringify(role?.rules) !== JSON.stringify(expectedRoleRules) ||
|
||||
JSON.stringify(roleBinding?.subjects) !==
|
||||
JSON.stringify([
|
||||
{
|
||||
kind: 'ServiceAccount',
|
||||
name,
|
||||
namespace: 'qinglong3-system',
|
||||
},
|
||||
]) ||
|
||||
JSON.stringify(roleBinding?.roleRef) !==
|
||||
JSON.stringify({
|
||||
apiGroup: 'rbac.authorization.k8s.io',
|
||||
kind: 'Role',
|
||||
name,
|
||||
}) ||
|
||||
admissionPolicy?.spec?.failurePolicy !== 'Fail' ||
|
||||
admissionPolicy?.spec?.paramKind?.apiVersion !== 'v1' ||
|
||||
admissionPolicy?.spec?.paramKind?.kind !== 'ConfigMap' ||
|
||||
admissionPolicy?.spec?.matchConstraints?.resourceRules?.[0]?.operations?.[0] !==
|
||||
'CREATE' ||
|
||||
admissionPolicy?.spec?.matchConstraints?.resourceRules?.[0]?.resources?.[0] !==
|
||||
'jobs' ||
|
||||
admissionPolicy?.spec?.matchConditions?.[0]?.expression !==
|
||||
"request.userInfo.username == 'system:serviceaccount:qinglong3-system:ql3-plugin-package-executor'" ||
|
||||
!admissionExpressions.includes("variables.executor.image == params.data.image") ||
|
||||
!admissionExpressions.includes('variables.pod.automountServiceAccountToken == false') ||
|
||||
!admissionExpressions.includes('variables.values.secret.items.all') ||
|
||||
admissionBinding?.spec?.policyName !== actionName ||
|
||||
admissionBinding?.spec?.paramRef?.name !== actionName + '-admission' ||
|
||||
admissionBinding?.spec?.paramRef?.namespace !== 'qinglong3-system' ||
|
||||
admissionBinding?.spec?.paramRef?.parameterNotFoundAction !== 'Deny' ||
|
||||
JSON.stringify(admissionBinding?.spec?.validationActions) !==
|
||||
JSON.stringify(['Deny']) ||
|
||||
admissionConfig?.data?.serviceAccountName !== actionName ||
|
||||
admissionConfig?.data?.sourceSecretName !==
|
||||
'ql3-cluster-plugin-package-values' ||
|
||||
!String(admissionConfig?.data?.image).endsWith(
|
||||
'@sha256:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
)
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_PLUGIN_EXECUTOR_KUBERNETES_AUTHORITY',
|
||||
'Plugin Package controller must have create/get-only Job RBAC behind deny-on-error exact-contract admission and a tokenless action identity',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3254,6 +3344,9 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
['QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS', '600000'],
|
||||
['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE', '16'],
|
||||
['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES', '16'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED', 'true'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT', '8'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_NAMESPACE', 'qinglong3-system'],
|
||||
['QL3_POSTGRES_TLS_MODE', 'verify-full'],
|
||||
[
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
@@ -3270,6 +3363,50 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [environmentName, key] of [
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_SERVICE_ACCOUNT', 'serviceAccountName'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_SOURCE_SECRET', 'sourceSecretName'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE', 'image'],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET',
|
||||
'postgresCaSecretName',
|
||||
],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY', 'postgresCaKey'],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME',
|
||||
'postgresServerName',
|
||||
],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET',
|
||||
'postgresUrlSecretName',
|
||||
],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY', 'postgresUrlSecretKey'],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_AUTH_SECRET',
|
||||
'postgresAuthSecretName',
|
||||
],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_HOST', 'postgresHost'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PORT', 'postgresPort'],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_DATABASE', 'postgresDatabase'],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_USERNAME_KEY',
|
||||
'postgresUsernameKey',
|
||||
],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PASSWORD_KEY',
|
||||
'postgresPasswordKey',
|
||||
],
|
||||
]) {
|
||||
const ref = env.get(environmentName)?.valueFrom?.configMapKeyRef;
|
||||
if (ref?.name !== actionName + '-admission' || ref?.key !== key) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_PLUGIN_EXECUTOR_SECRET_ACTION_CONFIG',
|
||||
`${environmentName} must come from the admission-bound ConfigMap`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const executorUrl = env.get('QL3_POSTGRES_PACKAGE_EXECUTOR_URL')?.valueFrom
|
||||
?.secretKeyRef;
|
||||
if (
|
||||
@@ -3348,6 +3485,38 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
),
|
||||
);
|
||||
}
|
||||
const apiServerEgressExample = yaml.load(
|
||||
readFile(
|
||||
path.join(operationRoot, 'api-server-egress-patch.example.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(apiServerEgressExample) !==
|
||||
JSON.stringify([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/egress/-',
|
||||
value: {
|
||||
to: [
|
||||
{
|
||||
ipBlock: {
|
||||
cidr: 'REPLACE_WITH_API_SERVER_CIDR',
|
||||
},
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: 'TCP', port: 443 }],
|
||||
},
|
||||
},
|
||||
])
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_PLUGIN_EXECUTOR_API_EGRESS',
|
||||
'Plugin Package controller API egress example must require one exact private-overlay CIDR and TCP 443',
|
||||
),
|
||||
);
|
||||
}
|
||||
const cloudNativeRoot = path.join(operationRoot, 'cloudnative-pg');
|
||||
const cloudNative = yaml.load(
|
||||
readFile(path.join(cloudNativeRoot, 'kustomization.yaml'), 'utf8'),
|
||||
@@ -3364,12 +3533,21 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
)
|
||||
: undefined;
|
||||
const cloudNativeEnv = environmentByName({ env: environmentPatch?.value });
|
||||
const secretActionConfigPatch = yaml.load(
|
||||
readFile(
|
||||
path.join(cloudNativeRoot, 'secret-action-admission-config-patch.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const secretActionConfig = secretActionConfigPatch?.[0]?.value;
|
||||
const unreleasedDigest =
|
||||
'sha256:0000000000000000000000000000000000000000000000000000000000000000';
|
||||
if (
|
||||
JSON.stringify(cloudNative?.resources) !== JSON.stringify(['../base']) ||
|
||||
cloudNative?.patches?.[0]?.path !== 'cron-job-patch.yaml' ||
|
||||
cloudNative?.patches?.[1]?.path !== 'network-policy-patch.yaml' ||
|
||||
cloudNative?.patches?.[2]?.path !==
|
||||
'secret-action-admission-config-patch.yaml' ||
|
||||
JSON.stringify(cloudNative?.images) !==
|
||||
JSON.stringify([
|
||||
{
|
||||
@@ -3387,6 +3565,19 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
?.secretKeyRef?.name !== 'ql3-postgres-package-executor-auth' ||
|
||||
cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD')?.valueFrom
|
||||
?.secretKeyRef?.name !== 'ql3-postgres-package-executor-auth' ||
|
||||
secretActionConfigPatch?.[0]?.op !== 'replace' ||
|
||||
secretActionConfigPatch?.[0]?.path !== '/data' ||
|
||||
secretActionConfig?.postgresConnectionMode !== 'fields' ||
|
||||
secretActionConfig?.postgresCaSecretName !== 'ql3-postgres-ca' ||
|
||||
secretActionConfig?.postgresServerName !==
|
||||
'ql3-postgres-rw.qinglong3-system.svc' ||
|
||||
secretActionConfig?.postgresAuthSecretName !==
|
||||
'ql3-postgres-package-executor-auth' ||
|
||||
secretActionConfig?.postgresHost !==
|
||||
'ql3-postgres-rw.qinglong3-system.svc' ||
|
||||
secretActionConfig?.postgresPort !== '5432' ||
|
||||
secretActionConfig?.postgresDatabase !== 'qinglong' ||
|
||||
!String(secretActionConfig?.image).endsWith(`@${unreleasedDigest}`) ||
|
||||
!patch.some(
|
||||
(operation) =>
|
||||
operation?.path ===
|
||||
|
||||
@@ -64,6 +64,56 @@ test('requires every Cluster Admin Kubernetes workload to override the image com
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened Plugin Package Secret action Kubernetes authority', () => {
|
||||
const widenedRole = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/role.yaml',
|
||||
(source) => source.replace(' - get\n', ' - get\n - list\n'),
|
||||
),
|
||||
});
|
||||
assert.equal(widenedRole.compatible, false);
|
||||
assert.equal(
|
||||
widenedRole.findings.some(
|
||||
({ code }) => code === 'QL3_CLUSTER_PLUGIN_EXECUTOR_KUBERNETES_AUTHORITY',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const ignoredAdmissionFailure = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/validating-admission-policy.yaml',
|
||||
(source) => source.replace('failurePolicy: Fail', 'failurePolicy: Ignore'),
|
||||
),
|
||||
});
|
||||
assert.equal(ignoredAdmissionFailure.compatible, false);
|
||||
assert.equal(
|
||||
ignoredAdmissionFailure.findings.some(
|
||||
({ code }) => code === 'QL3_CLUSTER_PLUGIN_EXECUTOR_KUBERNETES_AUTHORITY',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const actionToken = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/secret-action-service-account.yaml',
|
||||
(source) => source.replace(
|
||||
'automountServiceAccountToken: false',
|
||||
'automountServiceAccountToken: true',
|
||||
),
|
||||
),
|
||||
});
|
||||
assert.equal(actionToken.compatible, false);
|
||||
assert.equal(
|
||||
actionToken.findings.some(
|
||||
({ code }) => code === 'QL3_CLUSTER_PLUGIN_EXECUTOR_LIFECYCLE',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires the bounded Cluster product facade and image entrypoint', () => {
|
||||
const missingBinary = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
|
||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 108,
|
||||
sourceFiles: 109,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 61,
|
||||
nestedSourceFiles: 107,
|
||||
nestedSourceFiles: 108,
|
||||
rootSourceFileRoles: {
|
||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user