Revert "Revert log redaction changes"

This reverts commit de6899c7c4.

This adds the log redaction stack back in one change so that we
can improve upon it.

Change-Id: I3a59a5b4fb18a15a6c89667e7ea434f3846a353a
This commit is contained in:
James E. Blair
2026-08-31 11:51:09 -07:00
parent 7259394aaf
commit cdbdb85201
32 changed files with 1122 additions and 440 deletions
+24
View File
@@ -535,6 +535,30 @@ Here is an example of two job definitions:
ssh_key:
key: decrypted-secret-key-data
.. attr:: secret-exposure
:default: ignore
Zuul can monitor the output of a build in real time and detect if
one of the Zuul secrets has been exposed in either the build's
streaming log or JSON log. It does not scan every output or log
file, so its effectiveness is limited, but it may detect some
common methods of accidental exposure.
.. value:: ignore
The default is not to scan for secrets in build output.
.. value:: redact
The line containing the secret is replaced with a message
indicating a secret would have been exposed. In the JSON
output, the entire playbook is replaced with a message.
.. value:: fail
In addition to the actions in `redact`, the build is failed
and aborted immediately.
.. attr:: nodeset
The nodes which should be supplied to the job. This parameter
+6
View File
@@ -264,3 +264,9 @@ Version 37
:Prior Zuul version: 14.0.0
:Description: Upgrade to per-project branch cache.
Affects schedulers and web.
Version 38
----------
:Prior Zuul version: 14.2.0
:Description: Add secret_exposure to FrozenJob.
Affects schedulers and executors.
+38 -1
View File
@@ -1722,7 +1722,14 @@ sensitive data must be provided to dependent jobs, the ``secret_data``
attribute may be used instead, and the data will be provided via the
same mechanism as job secrets, where the data are not written to disk
in the work directory. Care must still be taken to avoid displaying
or storing sensitive data within the job. For example:
or storing sensitive data within the job.
If a job is configured with :attr:`job.secret-exposure` to redact or
fail if a secret is exposed, any data returned using ``secret_data``
will be automatically included in the list of secrets that Zuul
searches for, starting with the next playbook.
For example:
.. code-block:: yaml
@@ -1923,6 +1930,36 @@ For example the following would skip retrying the build:
zuul:
retry: false
Adding redactions
~~~~~~~~~~~~~~~~~
If a job is configured with :attr:`job.secret-exposure` to redact or
fail if a secret is exposed, the job may add additional confidental
data for the detection system to use. This may be useful for jobs
that generate or obtain a token from an external source. Confidential
data may be registered with Zuul at the end of the playbook where it
was obtained using *zuul_return*. Zuul will search for it as it does
other secrets beginning with the next playbook (but not the playbook
that registers the data).
Any secrets in secret_data are automatically added and do not need to
be explicitly registered.
For example:
.. code-block:: yaml
tasks:
- zuul_return:
data:
zuul:
redactions:
- my_secret_token
If *zuul_return* is invoked multiple times (e.g., via multiple
playbooks), then the elements of **zuul.redactions** from each
invocation will be appended.
.. _build_status:
Ansible Groups
+6 -6
View File
@@ -31,8 +31,8 @@
- name: Save output
shell: |
mv job-output.txt job-output-success-19887.txt
mv job-output.json job-output-success-19887.json
mv test-job-output.txt job-output-success-19887.txt
mv test-job-output.json job-output-success-19887.json
# Streamer puts out a line like
# [node1] Starting to log 916b2084-4bbb-80e5-248e-000000000016-1-node1 for task TASK: Print binary data
@@ -71,8 +71,8 @@
- name: Save output
shell: |
mv job-output.txt job-output-success-19885.txt
mv job-output.json job-output-success-19885.json
mv test-job-output.txt job-output-success-19885.txt
mv test-job-output.json job-output-success-19885.json
- name: Validate text outputs
include_tasks: validate.yaml
@@ -113,8 +113,8 @@
- name: Save output
shell: |
mv job-output.txt job-output-failure.txt
mv job-output.json job-output-failure.json
mv test-job-output.txt job-output-failure.txt
mv test-job-output.json job-output-failure.json
- name: Validate output - failure shell task with exception
shell: |
+12 -1
View File
@@ -23,7 +23,18 @@ def main():
output_txt_path = sys.argv[2]
output_json_path = sys.argv[3]
with open(output_json_path) as f:
output_json = json.loads(f.read())
# Our test logging configuration just outputs json blobs for
# each playbook and does not assemble them into an array like
# the log receiver does; therefore we must do that here.
data = '['
first = True
for line in f:
if not first and line == '{':
line = ',' + line
first = False
data += line
data += ']'
output_json = json.loads(data)
with open(output_txt_path) as f:
output_txt = f.read()
with open(console_path) as f:
@@ -0,0 +1,7 @@
---
features:
- |
Zuul can now optionally monitor the output of a build in real time
and detect if one of the Zuul secrets has been exposed in either
the build's streaming log or JSON log. See
:attr:`job.secret-exposure`.
+14 -2
View File
@@ -847,7 +847,8 @@ class RecordingAnsibleJob(zuul.executor.server.AnsibleJob):
self.result, unreachable, error_detail = super(
RecordingAnsibleJob, self).runPlaybooks(args)
self.recordResult(self.result)
if not self.executor_server._new_result_behavior:
self.recordResult(self.result)
return self.result, unreachable, error_detail
def runAnsible(self, cmd, timeout, playbook, ansible_version,
@@ -901,7 +902,8 @@ class RecordingAnsibleJob(zuul.executor.server.AnsibleJob):
super().resume()
def _send_aborted(self):
self.recordResult('ABORTED')
if not self.executor_server._new_result_behavior:
self.recordResult('ABORTED')
super()._send_aborted()
@@ -1115,6 +1117,7 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
_job_class = RecordingAnsibleJob
_merger_api_class = TestingMergerApi
_new_result_behavior = False
def __init__(self, *args, **kw):
self._run_ansible = kw.pop('_run_ansible', False)
@@ -1236,6 +1239,15 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
build.release()
super(RecordingExecutorServer, self).stop()
def completeBuild(self, build_request, result_data):
# TODO: change all existing tests to use this behavior since
# it more accurately reflects what the scheduler sees.
if self._new_result_behavior:
job_worker = self.job_workers.get(build_request.uuid)
job_worker.recordResult(result_data['result'])
super(RecordingExecutorServer, self).completeBuild(
build_request, result_data)
class TestScheduler(zuul.scheduler.Scheduler):
_merger_client_class = HoldableMergeClient
@@ -0,0 +1,9 @@
- hosts: all
tasks:
- debug:
msg: "Username: {{ test_secret.username }}"
- hosts: all
tasks:
- debug:
msg: "Password: {{ test_secret.password }}"
@@ -0,0 +1,4 @@
- hosts: all
tasks:
- set_fact:
output: "Username: {{ test_secret.username }}"
@@ -0,0 +1,4 @@
- hosts: all
tasks:
- set_fact:
output: "Password: {{ test_secret.complex_data.password }}"
@@ -0,0 +1,17 @@
- hosts: all
tasks:
- shell: |
echo "Okay to expose this: test-password"
- zuul_return:
data:
zuul:
redactions:
- zuul-return-redaction-1
- zuul_return:
data:
zuul:
redactions:
- zuul-return-redaction-2
secret_data:
complex:
data: zuul-return-redaction-3
@@ -0,0 +1,14 @@
- hosts: all
tasks:
- shell: |
echo "Username: {{ test_secret.username }}"
- shell: |
echo "Password: {{ test_secret.complex_data.password }}"
- shell: |
echo "OIDC: {{ oidc_secret }}"
- shell: |
echo "Zuul return: zuul-return-redaction-1"
- shell: |
echo "Zuul return: zuul-return-redaction-2"
- shell: |
echo "Zuul return: zuul-return-redaction-3"
@@ -97,6 +97,82 @@
files:
- postsyntax
- secret:
name: test-secret
data:
username: test-username
complex_data:
password: !encrypted/pkcs1-oaep |
BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71Y
Usi1wGZZL0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4j
oeusC9drN3AA8a4oykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CR
gd0QBMPl6VDoFgBPB8vxtJw+3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzi
bDsSXsfJt1y+5n7yOURsC7lovMg4GF/vCl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCY
ceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qtxhbpjTxG4U5Q/SoppOJ60WqEkQvb
Xs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYraI+AKYsMYx3RBlfAmCeC
1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFWZ3QSO1NjbBxW
naHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd+150
AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZH
vIs=
- secret:
name: oidc-secret
oidc:
- job:
name: expose-secrets-parent
pre-run: playbooks/expose-secrets-parent.yaml
- job:
name: expose-secrets-txt
parent: expose-secrets-parent
run: playbooks/expose-secrets-txt.yaml
secrets:
- name: test_secret
secret: test-secret
- name: oidc_secret
secret: oidc-secret
- job:
name: expose-secrets-json
parent: expose-secrets-parent
run:
- playbooks/expose-secrets-json1.yaml
- playbooks/expose-secrets-json2.yaml
secrets:
- name: test_secret
secret: test-secret
- job:
name: expose-secrets-txt-ignore
parent: expose-secrets-txt
secret-exposure: ignore
- job:
name: expose-secrets-json-ignore
parent: expose-secrets-json
secret-exposure: ignore
- job:
name: expose-secrets-txt-redact
parent: expose-secrets-txt
secret-exposure: redact
- job:
name: expose-secrets-json-redact
parent: expose-secrets-json
secret-exposure: redact
- job:
name: expose-secrets-txt-fail
parent: expose-secrets-txt
secret-exposure: fail
- job:
name: expose-secrets-json-fail
parent: expose-secrets-json
secret-exposure: fail
- project:
name: org/project
check:
@@ -138,3 +214,14 @@
- pre-syntax
- run-syntax
- post-syntax
- project:
name: org/project7
check:
jobs:
- expose-secrets-txt-ignore
- expose-secrets-json-ignore
- expose-secrets-txt-redact
- expose-secrets-json-redact
- expose-secrets-txt-fail
- expose-secrets-json-fail
@@ -0,0 +1 @@
test
+1
View File
@@ -11,3 +11,4 @@
- org/project4
- org/project5
- org/project6
- org/project7
@@ -1,9 +0,0 @@
- hosts: localhost
tasks:
- command: ln -s -f /etc/passwd "{{ zuul.executor.log_root }}/job-output.txt"
# Pause the job for the side-effect of the executor writing to the
# job-output.txt file.
- zuul_return:
data:
zuul:
pause: true
@@ -1,20 +0,0 @@
- pipeline:
name: check
manager: independent
trigger:
gerrit:
- event: patchset-created
- event: comment-added
comment: '^(Patch Set [0-9]+:\n\n)?(?i:recheck)$'
success:
gerrit:
Verified: 1
failure:
gerrit:
Verified: -1
- job:
name: base
run: playbooks/run.yaml
parent: null
attempts: 1
@@ -1,7 +0,0 @@
- job:
name: testjob
- project:
check:
jobs:
- testjob
-8
View File
@@ -1,8 +0,0 @@
- tenant:
name: tenant-one
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project
+17 -16
View File
@@ -9691,22 +9691,23 @@ class TestSchedulerFailFast(ZuulTestCase):
# Release the failing build first so it is the first
# result event to be processed.
job_workers = self.executor_server.job_workers.copy()
released = self.executor_server.release('project-test1')
for _ in iterate_timeout(10, 'project-test1 to be released'):
if len(self.builds) == 1:
break
fake_build = released[0]
job = job_workers.get(fake_build.build_request.uuid)
job.wait()
# Release successful build and wait for it to be gone,
# so both result events are processed in the same iteration.
released = self.executor_server.release('project-test2')
for _ in iterate_timeout(10, 'project-test2 to be released'):
if len(self.builds) == 0:
break
fake_build = released[0]
job = job_workers.get(fake_build.build_request.uuid)
job.wait()
with self.scheds.first.sched.run_handler_lock:
released = self.executor_server.release('project-test1')
for _ in iterate_timeout(10, 'project-test1 to be released'):
if len(self.builds) == 1:
break
fake_build = released[0]
job = job_workers.get(fake_build.build_request.uuid)
job.wait()
# Release successful build and wait for it to be gone,
# so both result events are processed in the same iteration.
released = self.executor_server.release('project-test2')
for _ in iterate_timeout(10, 'project-test2 to be released'):
if len(self.builds) == 0:
break
fake_build = released[0]
job = job_workers.get(fake_build.build_request.uuid)
job.wait()
self.executor_server.hold_jobs_in_build = False
self.fake_nodepool.unpause()
+148 -14
View File
@@ -15,6 +15,7 @@
import base64
import copy
import getpass
import io
import json
import jwt
@@ -38,6 +39,7 @@ import git
import paramiko
import zuul.configloader
from zuul.driver import bubblewrap
from zuul.lib import yamlutil as yaml
from zuul.model import MergeRequest, SEVERITY_WARNING
from zuul.zk.blob_store import BlobStore
@@ -3302,6 +3304,30 @@ class TestInRepoConfig(ZuulTestCase):
self.assertEqual(30, build.job.timeout)
self.assertEqual(30, build.job.pre_timeout)
def test_secret_exposure(self):
in_repo_conf = textwrap.dedent(
"""
- job:
name: parent
secret-exposure: fail
- job:
name: project-test1
parent: parent
secret-exposure: redact
run: playbooks/project-test1.yaml
- project:
check:
jobs: ['project-test1']
""")
file_dict = {'.zuul.yaml': in_repo_conf}
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertEqual(A.reported, 1)
self.assertHistory([])
self.assertIn('Unable to reset secret-exposure', A.messages[0])
class TestInRepoConfigSOS(ZuulTestCase):
config_file = 'zuul-connections-gerrit-and-github.conf'
@@ -9163,6 +9189,8 @@ class TestSecretLeaks(AnsibleZuulTestCase):
matches = []
for (dirpath, dirnames, filenames) in os.walk(path):
for filename in filenames:
if filename == 'log.socket':
continue
filepath = os.path.join(dirpath, filename)
with open(filepath, 'rb') as f:
if content in f.read():
@@ -9247,6 +9275,8 @@ class TestParseErrors(AnsibleZuulTestCase):
matches = []
for (dirpath, dirnames, filenames) in os.walk(path):
for filename in filenames:
if filename == 'log.socket':
continue
filepath = os.path.join(dirpath, filename)
with open(filepath, 'rb') as f:
if content in f.read():
@@ -9555,6 +9585,17 @@ class TestJobOutput(AnsibleZuulTestCase):
with open(p) as f:
return f.read()
def _getSecrets(self, job, pbtype):
secrets = []
build = self.getJobFromHistory(job)
for pb in getattr(build.jobdir, pbtype):
if pb.secrets_content:
secrets.append(
yamlutil.ansible_unsafe_load(pb.secrets_content))
else:
secrets.append({})
return secrets
def test_job_output_split_streams(self):
# Verify that command standard output appears in the job output,
# and that failures in the final playbook get logged.
@@ -9912,6 +9953,113 @@ class TestJobOutput(AnsibleZuulTestCase):
result = j[1]['plays'][0]['play']['name']
self.assertIn(expected_str, result)
def test_job_output_redact_secrets(self):
# If we can test cgroups, exercise the cgroup code path in
# this test
if os.environ.get("ZUUL_TEST_CGROUPS", None):
user = getpass.getuser()
memory_limit = '2G'
cgroup_manager = bubblewrap.CgroupManager(user, memory_limit)
self.executor_server.execution_wrapper.setCgroupManager(
cgroup_manager)
self.executor_server.keep_jobdir = True
self.executor_server._new_result_behavior = True
A = self.fake_gerrit.addFakeChange('org/project7', 'master', 'A')
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
es = 'expose-secrets'
self.assertHistory([
dict(name=f'{es}-txt-ignore', result='SUCCESS', changes='1,1'),
dict(name=f'{es}-json-ignore', result='SUCCESS', changes='1,1'),
dict(name=f'{es}-txt-redact', result='SUCCESS', changes='1,1'),
dict(name=f'{es}-json-redact', result='SUCCESS', changes='1,1'),
dict(name=f'{es}-txt-fail',
result='SECRET_EXPOSURE', changes='1,1'),
dict(name=f'{es}-json-fail',
result='SECRET_EXPOSURE', changes='1,1'),
], ordered=False)
# Check Ignore
job1 = self.getJobFromHistory(f'{es}-txt-ignore')
job2 = self.getJobFromHistory(f'{es}-json-ignore')
json_output = self._get_file(job2, 'work/logs/job-output.json')
self.log.info(json_output)
data = json.loads(json_output)
self.assertEqual(3, len(data))
self.assertEqual('Username: test-username',
data[1]['plays'][0]['tasks'][0]
['hosts']['test_node']['ansible_facts']['output'])
# This is a secret exposed
self.assertEqual('Password: test-password',
data[2]['plays'][0]['tasks'][0]
['hosts']['test_node']['ansible_facts']['output'])
job_output = self._get_file(job1, 'work/logs/job-output.txt')
self.log.info(job_output)
self.assertIn("Job console starting", job_output)
self.assertIn("Username: test-username", job_output)
self.assertIn("Okay to expose this: test-password", job_output)
# This is a secret exposed
self.assertIn("Password: test-password", job_output)
# Check Redact
job1 = self.getJobFromHistory(f'{es}-txt-redact')
job2 = self.getJobFromHistory(f'{es}-json-redact')
json_output = self._get_file(job2, 'work/logs/job-output.json')
self.log.info(json_output)
data = json.loads(json_output)
self.assertEqual(3, len(data))
self.assertEqual('Username: test-username',
data[1]['plays'][0]['tasks'][0]
['hosts']['test_node']['ansible_facts']['output'])
# This is a redaction
self.assertEqual('[Zuul] Secret detected in output',
data[2]['plays'][0]['play']['name'])
job_output = self._get_file(job1, 'work/logs/job-output.txt')
self.log.info(job_output)
self.assertIn("Job console starting", job_output)
self.assertIn("Username: test-username", job_output)
self.assertIn("Okay to expose this: test-password", job_output)
# This is a redaction
self.assertNotIn("Password:", job_output)
self.assertNotIn("zuul-return-redaction-1", job_output)
self.assertNotIn("zuul-return-redaction-2", job_output)
self.assertNotIn("zuul-return-redaction-3", job_output)
self.assertIn("[Zuul] Secret detected in output", job_output)
# We check the OIDC secret here
secrets = self._getSecrets(
f'{es}-txt-redact', 'playbooks'
)[0]
self.assertEqual(len(secrets), 2)
token = secrets['oidc_secret'].value
self.assertNotIn("OIDC:", job_output)
self.assertNotIn(token, job_output)
# Check Fail
job1 = self.getJobFromHistory(f'{es}-txt-fail')
job2 = self.getJobFromHistory(f'{es}-json-fail')
job_output = self._get_file(job2, 'work/logs/job-output.txt')
self.log.info(job_output)
json_output = self._get_file(job2, 'work/logs/job-output.json')
self.log.info(json_output)
data = json.loads(json_output)
self.assertEqual(3, len(data))
self.assertEqual('Username: test-username',
data[1]['plays'][0]['tasks'][0]
['hosts']['test_node']['ansible_facts']['output'])
self.assertEqual('[Zuul] Secret detected in output',
data[2]['plays'][0]['play']['name'])
job_output = self._get_file(job1, 'work/logs/job-output.txt')
self.log.info(job_output)
self.assertIn("Job console starting", job_output)
self.assertIn("Username: test-username", job_output)
self.assertIn("Okay to expose this: test-password", job_output)
self.assertNotIn("Password:", job_output)
class TestNoLog(AnsibleZuulTestCase):
tenant_config_file = 'config/ansible-no-log/main.yaml'
@@ -11619,20 +11767,6 @@ class TestConnectionVars(AnsibleZuulTestCase):
# self.assertNotIn("/bin/du", job_output)
class TestSymlinkEscape(AnsibleZuulTestCase):
tenant_config_file = 'config/symlink-escape/main.yaml'
@okay_tracebacks('ValueError: Symlink detected')
def test_symlink_escape(self):
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertHistory([])
self.assertEqual(A.reported, 1)
self.assertIn("ERROR", A.messages[0])
self.assertIn("Symlink detected", A.messages[0])
class IncludeBranchesTestCase(ZuulTestCase):
def _test_include_branches(self, history1, history2, history3, history4):
self.create_branch('org/project', 'stable')
+9
View File
@@ -407,6 +407,7 @@ class TestWeb(BaseTestWeb):
'project': 'org/common-config'},
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -496,6 +497,7 @@ class TestWeb(BaseTestWeb):
'source_context': source_ctx,
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -558,6 +560,7 @@ class TestWeb(BaseTestWeb):
'source_context': source_ctx,
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -613,6 +616,7 @@ class TestWeb(BaseTestWeb):
'source_context': source_ctx,
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -751,6 +755,7 @@ class TestWeb(BaseTestWeb):
'project': 'common-config'},
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -802,6 +807,7 @@ class TestWeb(BaseTestWeb):
'project': 'common-config'},
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -853,6 +859,7 @@ class TestWeb(BaseTestWeb):
'project': 'common-config'},
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -904,6 +911,7 @@ class TestWeb(BaseTestWeb):
'project': 'common-config'},
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
@@ -984,6 +992,7 @@ class TestWeb(BaseTestWeb):
'project': 'common-config'},
'tags': [],
'type': 'regular',
'secret_exposure': 'ignore',
'pre_timeout': None,
'timeout': None,
'post_timeout': None,
+1
View File
@@ -104,6 +104,7 @@ class BuildsPageComponent extends React.Component {
'EXCEPTION',
'NO_HANDLE',
'SNAPSHOT_FAILURE',
'SECRET_EXPOSURE',
],
fuzzy: false,
},
+3
View File
@@ -63,6 +63,7 @@ def merge_data(dict_a, dict_b):
artifacts = merge_zuul_list(dict_a, dict_b, 'artifacts')
file_comments = merge_file_comments(dict_a, dict_b)
warnings = merge_zuul_list(dict_a, dict_b, 'warnings')
redactions = merge_zuul_list(dict_a, dict_b, 'redactions')
retry = dict_a.get('zuul', {}).get('retry')
merge_dict(dict_a, dict_b)
if artifacts:
@@ -71,6 +72,8 @@ def merge_data(dict_a, dict_b):
dict_b.setdefault("zuul", {})["file_comments"] = file_comments
if warnings:
dict_b.setdefault('zuul', {})['warnings'] = warnings
if redactions:
dict_b.setdefault('zuul', {})['redactions'] = redactions
if retry:
dict_b.setdefault('zuul', {})['retry'] = retry
+11 -22
View File
@@ -26,6 +26,8 @@ import copy
import datetime
import json
import os
import logging
import logging.config
from ansible.plugins.loader import PluginLoader
from ansible.plugins.callback import CallbackBase
@@ -57,13 +59,16 @@ class CallbackModule(CallbackBase):
super(CallbackModule, self).__init__(display)
self.results = []
self.playbook = {}
self._playbook_name = None
logging_config = logconfig.load_job_config(
os.environ['ZUUL_JOB_LOG_CONFIG'])
self.output_path = os.path.splitext(
logging_config.job_output_file)[0] + '.json'
if self._display.verbosity > 2:
logging_config.setDebug()
self._playbook_name = None
logging_config.apply()
self._logger = logging.getLogger('zuul.executor.ansible.json')
def _new_playbook(self, play):
extra_vars = play._variable_manager._extra_vars
@@ -168,25 +173,9 @@ class CallbackModule(CallbackBase):
self.playbook['plays'] = self.results
self.playbook['stats'] = summary
first_time = not os.path.exists(self.output_path)
if first_time:
with open(self.output_path, 'w') as outfile:
outfile.write('[\n\n]\n')
with open(self.output_path, 'r+') as outfile:
self._append_playbook(outfile, first_time)
def _append_playbook(self, outfile, first_time):
file_len = outfile.seek(0, os.SEEK_END)
# Remove three bytes to eat the trailing newline written by the
# json.dump. This puts the ',' on the end of lines.
outfile.seek(file_len - 3)
if not first_time:
outfile.write(',\n')
json.dump(self.playbook, outfile,
indent=4, sort_keys=True, separators=(',', ': '))
outfile.write('\n]\n')
self._logger.info(
json.dumps(self.playbook,
indent=4, sort_keys=True, separators=(',', ': ')))
v2_runner_on_unreachable = v2_runner_on_ok
+2 -2
View File
@@ -346,7 +346,7 @@ class CallbackModule(default.CallbackModule):
logging_config.apply()
self._logger = logging.getLogger('zuul.executor.ansible')
self._logger = logging.getLogger('zuul.executor.ansible.stream')
self._result_logger = logging.getLogger(
'zuul.executor.ansible.result')
@@ -379,7 +379,7 @@ class CallbackModule(default.CallbackModule):
msg = msg.rstrip()
if job:
now = ts or datetime.datetime.now()
self._logger.info("{now} | {msg}".format(now=now, msg=msg))
self._logger.debug("{now} | {msg}".format(now=now, msg=msg))
if executor:
if debug:
self._display.vvv(msg)
+46 -13
View File
@@ -43,9 +43,11 @@ _DEFAULT_JOB_LOGGING_CONFIG = {
},
'jobfile': {
# used by executor to emit log file
'class': 'logging.FileHandler',
'level': 'INFO',
'class': 'logging.handlers.SysLogHandler',
'level': 'DEBUG',
'formatter': 'plain',
'facility': 'local0',
'socktype': 1, # SOCK_STREAM
},
},
'loggers': {
@@ -53,10 +55,14 @@ _DEFAULT_JOB_LOGGING_CONFIG = {
'handlers': ['result'],
'level': 'INFO',
},
'zuul.executor.ansible': {
'zuul.executor.ansible.json': {
'handlers': ['jobfile'],
'level': 'INFO',
},
'zuul.executor.ansible.stream': {
'handlers': ['jobfile'],
'level': 'DEBUG',
},
'sqlalchemy.engine': {
'handlers': ['console'],
'level': 'WARN',
@@ -186,24 +192,49 @@ class DictLoggingConfig(LoggingConfig, metaclass=abc.ABCMeta):
class JobLoggingConfig(DictLoggingConfig):
def __init__(self, config=None, job_output_file=None):
def __init__(self, config=None, log_socket=None,
test_log_output_text_file=None,
test_log_output_json_file=None):
if not config:
config = copy.deepcopy(_DEFAULT_JOB_LOGGING_CONFIG)
super(JobLoggingConfig, self).__init__(config=config)
if job_output_file:
self.job_output_file = job_output_file
if log_socket:
self.log_socket = log_socket
if test_log_output_text_file:
# This is used by the streaming output test
self._config['handlers']['jobfile'] = {
'class': 'logging.FileHandler',
'level': 'DEBUG',
'formatter': 'plain',
'filename': test_log_output_text_file,
}
if test_log_output_json_file:
# This is used by the streaming output test
self._config['handlers']['jsonfile'] = {
'class': 'logging.FileHandler',
'level': 'INFO',
'formatter': 'plain',
'filename': test_log_output_json_file,
}
self._config['loggers']['zuul.executor.ansible.json'] = {
'handlers': ['jsonfile'],
'level': 'INFO',
}
def setDebug(self):
# Set the level for zuul.executor.ansible to DEBUG
self._config['loggers']['zuul.executor.ansible']['level'] = 'DEBUG'
self._config['loggers']['zuul.executor.ansible.stream']['level'] =\
'DEBUG'
self._config['loggers']['zuul.executor.ansible.json']['level'] =\
'DEBUG'
@property
def job_output_file(self) -> str:
return self._config['handlers']['jobfile']['filename']
def log_socket(self):
return self._config['handlers']['jobfile']['address']
@job_output_file.setter
def job_output_file(self, filename: str):
self._config['handlers']['jobfile']['filename'] = filename
@log_socket.setter
def log_socket(self, filename):
self._config['handlers']['jobfile']['address'] = filename
class ServerLoggingConfig(DictLoggingConfig):
@@ -257,5 +288,7 @@ if __name__ == '__main__':
# Use this to emit a working logging output for testing zuul_stream
# locally.
config = JobLoggingConfig(
job_output_file=os.path.abspath('job-output.txt'))
test_log_output_text_file=os.path.abspath('test-job-output.txt'),
test_log_output_json_file=os.path.abspath('test-job-output.json'),
)
config.writeJson('logging.json')
+10
View File
@@ -40,5 +40,15 @@ warning_data = {
vs.Extra: object,
}
redaction_data = {
'zuul': {
'log_url': str,
'redactions': [str],
vs.Extra: object,
},
vs.Extra: object,
}
artifact_schema = vs.Schema(artifact_data)
warning_schema = vs.Schema(warning_data)
redaction_schema = vs.Schema(redaction_data)
+2
View File
@@ -831,6 +831,7 @@ class JobParser(object):
'image-build-name': str,
'type': vs.Any('regular', 'initializer', 'reporter'),
'preserve-home-paths': to_list(str),
'secret-exposure': vs.Any('ignore', 'redact', 'fail'),
'attribute-control': {
vs.Any(
'requires',
@@ -890,6 +891,7 @@ class JobParser(object):
'deduplicate',
'image-build-name',
'type',
'secret-exposure',
]
attr_control_job_attr_map = {
+578 -318
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -16,6 +16,7 @@ import urllib.parse
from zuul.ansible.schema import (
artifact_schema,
redaction_schema,
warning_schema,
)
@@ -67,3 +68,15 @@ def get_warnings_from_result_data(result_data, logger=None):
if logger:
logger.debug("Result data did not pass warnings schema "
"validation: %s", result_data)
def pop_redactions_from_result_data(result_data, logger=None):
# This method removes the redactions so we don't keep them around
# longer than necessary and accidentally leak them.
if validate_schema(result_data, redaction_schema):
return result_data.get('zuul', {}).pop('redactions', [])
else:
if logger:
# We do not log the redaction values
logger.debug("Result data did not pass redactions schema "
"validation")
+38 -1
View File
@@ -3534,6 +3534,26 @@ class Secret(ConfigObject):
r.secret_data = self._decrypt(private_key, self.secret_data)
return r
def _decryptForCollection(self, private_key, secret_data):
# recursive function to decrypt data
if hasattr(secret_data, 'decrypt'):
yield secret_data.decrypt(private_key)
if isinstance(secret_data, (dict, types.MappingProxyType)):
for k, v in secret_data.items():
yield from self._decryptForCollection(private_key, v)
if isinstance(secret_data, (list, tuple)):
for v in secret_data:
yield from self._decryptForCollection(private_key, v)
def collectEncryptedValues(self, private_key):
"""Return a list of the decrypted contents of encrypted values
in this secret"""
return [x for x in self._decryptForCollection(
private_key, self.secret_data)]
def serialize(self, layout):
# The output of this method is used by the executor
# Set the ttl for this tenant
@@ -4025,6 +4045,7 @@ class FrozenJob(zkobject.ZKObject):
'image_build_name',
'include_vars',
'type',
'secret_exposure',
)
job_data_attributes = ('artifact_data',
@@ -4215,6 +4236,9 @@ class FrozenJob(zkobject.ZKObject):
data.setdefault('include_projects', None)
data.setdefault('exclude_projects', None)
# MODEL_API <= 38
data.setdefault('secret_exposure', 'ignore')
for job_data_key in self.job_data_attributes:
job_data = data.pop(job_data_key, None)
if job_data:
@@ -4522,6 +4546,7 @@ class Job(ConfigObject):
d['failure_output'] = self.failure_output
d['image_build_name'] = self.image_build_name
d['type'] = self.type
d['secret_exposure'] = self.secret_exposure
d['include_vars'] = list(map(lambda x: x.toDict(), self.include_vars))
if self.isBase():
d['parent'] = None
@@ -4612,6 +4637,7 @@ class Job(ConfigObject):
image_build_name=None,
type='regular',
preserve_home_paths=(),
secret_exposure='ignore',
)
override_control = defaultdict(lambda: True)
@@ -5454,7 +5480,8 @@ class Job(ConfigObject):
'required_projects', 'include_projects',
'exclude_projects', 'allowed_projects',
'semaphores', 'failure_output',
'include_vars', 'preserve_home_paths']):
'include_vars', 'preserve_home_paths',
'secret_exposure']):
setattr(self, k, other._get(k))
# Don't set final above so that we don't trip an error halfway
@@ -5505,6 +5532,16 @@ class Job(ConfigObject):
# Freeze the nodeset
self.nodeset = self.getNodeset(layout)
# Once set, the secret-exposure setting may not be changed
if secret_exposure := other._get('secret_exposure'):
if (self._get('secret_exposure') and
secret_exposure != self._get('secret_exposure')):
raise JobConfigurationError(
"Unable to reset secret-exposure attribute of job"
" %s by job %s" % (
repr(self), repr(other)))
self.secret_exposure = secret_exposure
# Pass secrets to parents
secrets_for_parents = [s for s in other.secrets if s.pass_to_parent]
if secrets_for_parents: