This repository has been archived by the owner on Oct 13, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 26
/
images_streams.py
1225 lines (1034 loc) · 62.9 KB
/
images_streams.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import io
import os
import click
import yaml
import json
import hashlib
import time
import datetime
import random
from typing import Dict
from github import Github, UnknownObjectException, GithubException
from jira import JIRA, Issue
from dockerfile_parse import DockerfileParser
from doozerlib.model import Missing
from doozerlib.pushd import Dir
from doozerlib.cli import cli, pass_runtime
from doozerlib import brew, state, exectools, model, constants
from doozerlib.image import ImageMetadata
from doozerlib.util import get_docker_config_json, convert_remote_git_to_ssh, \
split_git_url, remove_prefix, green_print,\
yellow_print, red_print, convert_remote_git_to_https, \
what_is_in_master, extract_version_fields, convert_remote_git_to_https
@cli.group("images:streams", short_help="Manage ART equivalent images in upstream CI.")
def images_streams():
"""
When changing streams.yml, the following sequence of operations is required.
\b
1. Set KUBECONFIG to the art-publish service account.
2. Run gen-buildconfigs with --apply or 'oc apply' it after the fact.
3. Run mirror verb to push the images to the api.ci cluster (consider if --only-if-missing is appropriate).
4. Run start-builds to trigger buildconfigs if they have not run already.
5. Run 'check-upstream' after about 10 minutes to make sure those builds have succeeded. Investigate any failures.
6. Run 'prs open' when check-upstream indicates all builds are complete.
To test changes before affecting CI:
\b
- You can run, 'gen-buildconfigs', 'start-builds', and 'check-upstream' with the --live-test-mode flag.
This will create and run real buildconfigs on api.ci, but they will not promote into locations used by CI.
- You can run 'prs open' with --moist-run. This will create forks for the target repos, but will only
print out PRs that would be opened instead of creating them.
"""
pass
@images_streams.command('mirror', short_help='Reads streams.yml and mirrors out ART equivalent images to api.ci.')
@click.option('--stream', 'streams', metavar='STREAM_NAME', default=[], multiple=True, help='If specified, only these stream names will be mirrored.')
@click.option('--only-if-missing', default=False, is_flag=True, help='Only mirror the image if there is presently no equivalent image upstream.')
@click.option('--live-test-mode', default=False, is_flag=True, help='Append "test" to destination images to exercise live-test-mode buildconfigs')
@click.option('--dry-run', default=False, is_flag=True, help='Do not build anything, but only print build operations.')
@pass_runtime
def images_streams_mirror(runtime, streams, only_if_missing, live_test_mode, dry_run):
runtime.initialize(clone_distgits=False, clone_source=False)
runtime.assert_mutation_is_permitted()
if streams:
user_specified = True
else:
user_specified = False
streams = runtime.get_stream_names()
upstreaming_entries = _get_upstreaming_entries(runtime, streams)
for upstream_entry_name, config in upstreaming_entries.items():
if config.mirror is True or user_specified:
upstream_dest = config.upstream_image
mirror_arm = False
if upstream_dest is Missing:
raise IOError(f'Unable to mirror {upstream_entry_name} since upstream_image is not defined')
# If the configuration specifies a upstream_image_base, then ART is responsible for mirroring
# that location and NOT the upstream_image. A buildconfig from gen-buildconfig is responsible
# for transforming upstream_image_base to upstream_image.
if config.upstream_image_base is not Missing:
upstream_dest = config.upstream_image_base
if "aarch64" in runtime.arches:
mirror_arm = True
brew_image = config.image
brew_pullspec = runtime.resolve_brew_image_url(brew_image)
if only_if_missing:
check_cmd = f'oc image info {upstream_dest}'
rc, check_out, check_err = exectools.cmd_gather(check_cmd)
if mirror_arm:
check_cmd_arm = f'oc image info {upstream_dest}-arm64'
rc_arm, check_out, check_err = exectools.cmd_gather(check_cmd_arm)
if rc == 0 and rc_arm == 0:
print(f'Image {upstream_dest} and {upstream_dest}-arm64 seems to exist already; skipping because of --only-if-missing')
continue
else:
if rc == 0:
print(f'Image {upstream_dest} seems to exist already; skipping because of --only-if-missing')
continue
if live_test_mode:
upstream_dest += '.test'
cmd = f'oc image mirror {brew_pullspec} {upstream_dest}'
if runtime.registry_config_dir is not None:
cmd += f" --registry-config={get_docker_config_json(runtime.registry_config_dir)}"
if dry_run:
print(f'For {upstream_entry_name}, would have run: {cmd}')
else:
exectools.cmd_assert(cmd, retries=3, realtime=True)
# mirror arm64 builder and base images for CI
if mirror_arm:
# oc image mirror will filter out missing arches (as long as the manifest is there) regardless of specifying --skip-missing
arm_cmd = f'oc image mirror --filter-by-os linux/arm64 {brew_pullspec} {upstream_dest}-arm64'
if runtime.registry_config_dir is not None:
arm_cmd += f" --registry-config={get_docker_config_json(runtime.registry_config_dir)}"
if dry_run:
print(f'For {upstream_entry_name}, would have run: {arm_cmd}')
else:
exectools.cmd_assert(arm_cmd, retries=3, realtime=True, timeout=1800)
@images_streams.command('check-upstream', short_help='Dumps information about CI buildconfigs/mirrored images associated with this group.')
@click.option('--live-test-mode', default=False, is_flag=True, help='Scan for live-test mode buildconfigs')
@pass_runtime
def images_streams_check_upstream(runtime, live_test_mode):
runtime.initialize(clone_distgits=False, clone_source=False)
istags_status = []
upstreaming_entries = _get_upstreaming_entries(runtime)
for upstream_entry_name, config in upstreaming_entries.items():
upstream_dest = config.upstream_image
_, dest_ns, dest_istag = upstream_dest.rsplit('/', maxsplit=2)
if live_test_mode:
dest_istag += '.test'
rc, stdout, stderr = exectools.cmd_gather(f'oc get -n {dest_ns} istag {dest_istag} --no-headers')
if rc:
istags_status.append(f'ERROR: {upstream_entry_name}\nIs not yet represented upstream in {dest_ns} istag/{dest_istag}')
else:
istags_status.append(f'OK: {upstream_entry_name} exists, but check whether it is recently updated\n{stdout}')
group_label = runtime.group_config.name
if live_test_mode:
group_label += '.test'
bc_stdout, bc_stderr = exectools.cmd_assert(f'oc -n ci get -o=wide buildconfigs -l art-builder-group={group_label}')
builds_stdout, builds_stderr = exectools.cmd_assert(f'oc -n ci get -o=wide builds -l art-builder-group={group_label}')
print('Build configs:')
print(bc_stdout or bc_stderr)
print('Recent builds:')
print(builds_stdout or builds_stderr)
print('Upstream imagestream tag status')
for istag_status in istags_status:
print(istag_status)
print()
@images_streams.command('start-builds', short_help='Triggers a build for each buildconfig associated with this group.')
@click.option('--as', 'as_user', metavar='CLUSTER_USERNAME', required=False, default=None, help='Specify --as during oc start-build')
@click.option('--live-test-mode', default=False, is_flag=True, help='Act on live-test mode buildconfigs')
@pass_runtime
def images_streams_start_buildconfigs(runtime, as_user, live_test_mode):
runtime.initialize(clone_distgits=False, clone_source=False)
group_label = runtime.group_config.name
if live_test_mode:
group_label += '.test'
cmd = f'oc -n ci get -o=name buildconfigs -l art-builder-group={group_label}'
if as_user:
cmd += f' --as {as_user}'
bc_stdout, bc_stderr = exectools.cmd_assert(cmd, retries=3)
bc_stdout = bc_stdout.strip()
if bc_stdout:
for name in bc_stdout.splitlines():
print(f'Triggering: {name}')
cmd = f'oc -n ci start-build {name}'
if as_user:
cmd += f' --as {as_user}'
stdout, stderr = exectools.cmd_assert(cmd, retries=3)
print(' ' + stdout or stderr)
else:
print(f'No buildconfigs associated with this group: {group_label}')
def _get_upstreaming_entries(runtime, stream_names=None):
"""
Looks through streams.yml entries and each image metadata for upstream
transform information.
:param runtime: doozer runtime
:param stream_names: A list of streams to look for in streams.yml. If None or empty, all will be searched.
:return: Returns a map[name] => { transform: '...', upstream_image.... } where name is a stream name or distgit key.
"""
if not stream_names:
# If not specified, use all.
stream_names = runtime.get_stream_names()
upstreaming_entries = {}
streams_config = runtime.streams
for stream in stream_names:
config = streams_config[stream]
if config is Missing:
raise IOError(f'Did not find stream {stream} in streams.yml for this group')
if config.upstream_image is not Missing:
upstreaming_entries[stream] = streams_config[stream]
# Some images also have their own upstream information. This allows them to
# be mirrored out into upstream, optionally transformed, and made available as builder images for
# other images without being in streams.yml.
for image_meta in runtime.ordered_image_metas():
if image_meta.config.content.source.ci_alignment.upstream_image is not Missing:
upstream_entry = model.Model(dict_to_model=image_meta.config.content.source.ci_alignment.primitive()) # Make a copy
upstream_entry['image'] = image_meta.pull_url() # Make the image metadata entry match what would exist in streams.yml.
if upstream_entry.final_user is Missing:
upstream_entry.final_user = image_meta.config.final_stage_user
upstreaming_entries[image_meta.distgit_key] = upstream_entry
return upstreaming_entries
@images_streams.command('gen-buildconfigs', short_help='Generates buildconfigs necessary to assemble ART equivalent images upstream.')
@click.option('--stream', 'streams', metavar='STREAM_NAME', default=[], multiple=True, help='If specified, only these stream names will be processed.')
@click.option('-o', '--output', metavar='FILENAME', required=True, help='The filename into which to write the YAML. It should be oc applied against api.ci as art-publish. The file may be empty if there are no buildconfigs.')
@click.option('--as', 'as_user', metavar='CLUSTER_USERNAME', required=False, default=None, help='Specify --as during oc apply')
@click.option('--apply', default=False, is_flag=True, help='Apply the output if any buildconfigs are generated')
@click.option('--live-test-mode', default=False, is_flag=True, help='Generate live-test mode buildconfigs')
@pass_runtime
def images_streams_gen_buildconfigs(runtime, streams, output, as_user, apply, live_test_mode):
"""
ART has a mandate to make "ART equivalent" images available usptream for CI workloads. This enables
CI to compile with the same golang version ART is using and use identical UBI8 images, etc. To accomplish
this, streams.yml contains metadata which is extraneous to the product build, but critical to enable
a high fidelity CI signal.
It may seem at first that all we would need to do was mirror the internal brew images we use
somewhere accessible by CI, but it is not that simple:
1. When a CI build yum installs, it needs to pull RPMs from an RPM mirroring service that runs in
CI. That mirroring service subsequently pulls and caches files ART syncs using reposync.
2. There is a variation of simple golang builders that CI uses to compile test cases. These
images are configured in ci-operator config's 'build_root' and they are used to build
and run test cases. Sometimes called 'CI release' image, these images contain tools that
are not part of the typical golang builder (e.g. tito).
Both of these differences require us to 'transform' the image ART uses in brew into an image compatible
for use in CI. A challenge to this transformation is that they must be performed in the CI context
as they depend on the services only available in ci (e.g. base-4-6-rhel8.ocp.svc is used to
find the current yum repo configuration which should be used).
To accomplish that, we don't build the images ourselves. We establish OpenShift buildconfigs on the CI
cluster which process intermediate images we push into the final, CI consumable image.
These buildconfigs are generated dynamically by this sub-verb.
The verb will also create a daemonset for the group on the CI cluster. This daemonset overcomes
a bug in OpenShift 3.11 where the kubelet can garbage collection an image that the build process
is about to use (because the kubelet & build do not communicate). The daemonset forces the kubelet
to know the image is in use. These daemonsets can like be eliminated when CI infra moves fully to
4.x.
"""
runtime.initialize(clone_distgits=False, clone_source=False)
runtime.assert_mutation_is_permitted()
transform_rhel_7_base_repos = 'rhel-7/base-repos'
transform_rhel_8_base_repos = 'rhel-8/base-repos'
transform_rhel_9_base_repos = 'rhel-9/base-repos'
transform_rhel_7_golang = 'rhel-7/golang'
transform_rhel_8_golang = 'rhel-8/golang'
transform_rhel_9_golang = 'rhel-9/golang'
transform_rhel_7_ci_build_root = 'rhel-7/ci-build-root'
transform_rhel_8_ci_build_root = 'rhel-8/ci-build-root'
transform_rhel_9_ci_build_root = 'rhel-9/ci-build-root'
# The set of valid transforms
transforms = set([
transform_rhel_7_base_repos,
transform_rhel_8_base_repos,
transform_rhel_9_base_repos,
transform_rhel_7_golang,
transform_rhel_8_golang,
transform_rhel_9_golang,
transform_rhel_7_ci_build_root,
transform_rhel_8_ci_build_root,
transform_rhel_9_ci_build_root,
])
major = runtime.group_config.vars['MAJOR']
minor = runtime.group_config.vars['MINOR']
rpm_repos_conf = runtime.group_config.repos or {}
group_label = runtime.group_config.name
if live_test_mode:
group_label += '.test'
buildconfig_definitions = []
upstreaming_entries = _get_upstreaming_entries(runtime, streams)
for upstream_entry_name, config in upstreaming_entries.items():
transform = config.transform
if transform is Missing:
# No buildconfig is necessary
continue
if transform not in transforms:
raise IOError(f'Unable to render buildconfig for upstream config {upstream_entry_name} - transform {transform} not found within {transforms}')
upstream_dest = config.upstream_image
upstream_intermediate_image = config.upstream_image_base
if upstream_dest is Missing or upstream_intermediate_image is Missing:
raise IOError(f'Unable to render buildconfig for upstream config {upstream_entry_name} - you must define upstream_image_base AND upstream_image')
# split a pullspec like registry.svc.ci.openshift.org/ocp/builder:rhel-8-golang-openshift-{MAJOR}.{MINOR}.art
# into OpenShift namespace, imagestream, and tag
_, intermediate_ns, intermediate_istag = upstream_intermediate_image.rsplit('/', maxsplit=2)
if live_test_mode:
intermediate_istag += '.test'
intermediate_imagestream, intermediate_tag = intermediate_istag.split(':')
_, dest_ns, dest_istag = upstream_dest.rsplit('/', maxsplit=2)
if live_test_mode:
dest_istag += '.test'
dest_imagestream, dest_tag = dest_istag.split(':')
python_file_dir = os.path.dirname(__file__)
# should align with files like: doozerlib/cli/ci_transforms/rhel-7/base-repos
# OR ocp-build-data branch ci_transforms/rhel-7/base-repos . The latter is given
# priority.
ocp_build_data_transform = os.path.join(runtime.data_dir, 'ci_transforms', transform, 'Dockerfile')
if os.path.exists(ocp_build_data_transform):
transform_template = os.path.join(runtime.data_dir, 'ci_transforms', transform, 'Dockerfile')
else:
# fall back to the doozerlib versions
transform_template = os.path.join(python_file_dir, 'ci_transforms', transform, 'Dockerfile')
with open(transform_template, mode='r', encoding='utf-8') as tt:
transform_template_content = tt.read()
dfp = DockerfileParser(cache_content=True, fileobj=io.BytesIO())
dfp.content = transform_template_content
# Make sure that upstream images can discern they are building in CI with ART equivalent images
dfp.envs['OPENSHIFT_CI'] = 'true'
dfp.labels['io.k8s.display-name'] = f'{dest_imagestream}-{dest_tag}'
dfp.labels['io.k8s.description'] = f'ART equivalent image {group_label}-{upstream_entry_name} - {transform}'
dfp.add_lines('USER 0') # Make sure we are root so that repos can be modified
def add_localdev_repo_profile(profile):
"""
The images we push to CI are used in two contexts:
1. In CI builds, running on the CI clusters.
2. In local development (e.g. docker build).
This method is enabling the latter. If a developer is connected to the VPN,
they will not be able to resolve RPMs through the RPM mirroring service running
on CI, but they will be able to pull RPMs directly from the sources ART does.
Since skip_if_unavailable is True globally, it doesn't matter if they can't be
accessed via CI.
"""
for repo_name in rpm_repos_conf.keys():
repo_desc = rpm_repos_conf[repo_name]
localdev_repo_name = f'localdev-{repo_name}'
repo_conf = repo_desc.conf
ci_alignment = repo_conf.ci_alignment
if ci_alignment.localdev.enabled and profile in ci_alignment.profiles:
# CI only really deals with x86_64 at this time.
if repo_conf.baseurl.unsigned:
x86_64_url = repo_conf.baseurl.unsigned.x86_64
else:
x86_64_url = repo_conf.baseurl.x86_64
if not x86_64_url:
raise IOError(f'Expected x86_64 baseurl for repo {repo_name}')
dfp.add_lines(f"RUN echo -e '[{localdev_repo_name}]\\nname = {localdev_repo_name}\\nid = {localdev_repo_name}\\nbaseurl = {x86_64_url}\\nenabled = 1\\ngpgcheck = 0\\nsslverify=0\\n' > /etc/yum.repos.d/{localdev_repo_name}.repo")
if transform == transform_rhel_9_base_repos or config.transform == transform_rhel_9_golang:
# The repos transform create a build config that will layer the base image with CI appropriate yum
# repository definitions.
dfp.add_lines(f'RUN rm -rf /etc/yum.repos.d/*.repo && curl http://base-{major}-{minor}-rhel9.ocp.svc > /etc/yum.repos.d/ci-rpm-mirrors.repo')
# Allow the base repos to be used BEFORE art begins mirroring 4.x to openshift mirrors.
# This allows us to establish this locations later -- only disrupting CI for those
# components that actually need reposync'd RPMs from the mirrors.
dfp.add_lines('RUN yum config-manager --setopt=skip_if_unavailable=True --save')
add_localdev_repo_profile('el9')
if transform == transform_rhel_8_base_repos or config.transform == transform_rhel_8_golang:
# The repos transform create a build config that will layer the base image with CI appropriate yum
# repository definitions.
dfp.add_lines(f'RUN rm -rf /etc/yum.repos.d/*.repo && curl http://base-{major}-{minor}-rhel8.ocp.svc > /etc/yum.repos.d/ci-rpm-mirrors.repo')
# Allow the base repos to be used BEFORE art begins mirroring 4.x to openshift mirrors.
# This allows us to establish this locations later -- only disrupting CI for those
# components that actually need reposync'd RPMs from the mirrors.
dfp.add_lines('RUN yum config-manager --setopt=skip_if_unavailable=True --save')
add_localdev_repo_profile('el8')
if transform == transform_rhel_7_base_repos or config.transform == transform_rhel_7_golang:
# The repos transform create a build config that will layer the base image with CI appropriate yum
# repository definitions.
dfp.add_lines(f'RUN rm -rf /etc/yum.repos.d/*.repo && curl http://base-{major}-{minor}.ocp.svc > /etc/yum.repos.d/ci-rpm-mirrors.repo')
# Allow the base repos to be used BEFORE art begins mirroring 4.x to openshift mirrors.
# This allows us to establish this locations later -- only disrupting CI for those
# components that actually need reposync'd RPMs from the mirrors.
add_localdev_repo_profile('el7')
dfp.add_lines("RUN yum-config-manager --save '--setopt=skip_if_unavailable=True'")
dfp.add_lines("RUN yum-config-manager --save '--setopt=*.skip_if_unavailable=True'")
if config.final_user:
# If the image should not run as root/0, then allow metadata to specify a
# true final user.
dfp.add_lines(f'USER {config.final_user}')
# We've arrived at a Dockerfile.
dockerfile_content = dfp.content
# Now to create a buildconfig for it.
buildconfig = {
'apiVersion': 'build.openshift.io/v1',
'kind': 'BuildConfig',
'metadata': {
'name': f'{dest_imagestream}-{dest_tag}--art-builder',
'namespace': 'ci',
'labels': {
'art-builder-group': group_label,
'art-builder-stream': upstream_entry_name,
},
'annotations': {
'description': 'Generated by the ART pipeline by doozer. Processes raw ART images into ART equivalent images for CI.'
}
},
'spec': {
'failedBuildsHistoryLimit': 2,
'output': {
'to': {
'kind': 'ImageStreamTag',
'namespace': dest_ns,
'name': dest_istag
}
},
'source': {
'dockerfile': dockerfile_content,
'type': 'Dockerfile'
},
'strategy': {
'dockerStrategy': {
'from': {
'kind': 'ImageStreamTag',
'name': intermediate_istag,
'namespace': intermediate_ns,
},
'imageOptimizationPolicy': 'SkipLayers',
},
},
'successfulBuildsHistoryLimit': 2,
'triggers': [{
'imageChange': {},
'type': 'ImageChange'
}]
}
}
buildconfig_definitions.append(buildconfig)
with open(output, mode='w+', encoding='utf-8') as f:
objects = list()
objects.extend(buildconfig_definitions)
yaml.dump_all(objects, f, default_flow_style=False)
if apply:
if buildconfig_definitions:
print('Applying buildconfigs...')
cmd = f'oc apply -f {output}'
if as_user:
cmd += f' --as {as_user}'
exectools.cmd_assert(cmd, retries=3)
else:
print('No buildconfigs were generated; skipping apply.')
def calc_parent_digest(parent_images):
m = hashlib.md5()
m.update(';'.join(parent_images).encode('utf-8'))
return m.hexdigest()
def extract_parent_digest(dockerfile_path):
with dockerfile_path.open(mode='r') as handle:
dfp = DockerfileParser(cache_content=True, fileobj=io.BytesIO())
dfp.content = handle.read()
return calc_parent_digest(dfp.parent_images), dfp.parent_images
def compute_dockerfile_digest(dockerfile_path):
with dockerfile_path.open(mode='r') as handle:
# Read in and standardize linefeed
content = '\n'.join(handle.read().splitlines())
m = hashlib.md5()
m.update(content.encode('utf-8'))
return m.hexdigest()
def resolve_upstream_from(runtime, image_entry):
"""
:param runtime: The runtime object
:param image_entry: A builder or from entry. e.g. { 'member': 'openshift-enterprise-base' } or { 'stream': 'golang; }
:return: The upstream CI pullspec to which this entry should resolve.
"""
major = runtime.group_config.vars['MAJOR']
minor = runtime.group_config.vars['MINOR']
if image_entry.member:
target_meta = runtime.resolve_image(image_entry.member, True)
if target_meta.config.content.source.ci_alignment.upstream_image is not Missing:
# If the upstream is specified in the metadata, use this information
# directly instead of a heuristic.
return target_meta.config.content.source.ci_alignment.upstream_image
else:
image_name = target_meta.config.name.split('/')[-1]
# In release payloads, images are promoted into an imagestream
# tag name without the ose- prefix.
image_name = remove_prefix(image_name, 'ose-')
# e.g. registry.ci.openshift.org/ocp/4.6:base
return f'registry.ci.openshift.org/ocp/{major}.{minor}:{image_name}'
if image_entry.image:
# CI is on its own. We can't give them an image that isn't available outside the firewall.
return None
elif image_entry.stream:
return runtime.resolve_stream(image_entry.stream).upstream_image
def _get_upstream_source(runtime, image_meta, skip_branch_check=False):
"""
Analyzes an image metadata to find the upstream URL and branch associated with its content.
:param runtime: The runtime object
:param image_meta: The metadata to inspect
:param skip_branch_check: Skip check branch exist every time
:return: A tuple containing (url, branch) for the upstream source OR (None, None) if there
is no upstream source.
"""
if "git" in image_meta.config.content.source:
source_repo_url = image_meta.config.content.source.git.url
source_repo_branch = image_meta.config.content.source.git.branch.target
if not skip_branch_check:
branch_check, err = exectools.cmd_assert(f'git ls-remote --heads {source_repo_url} {source_repo_branch}', strip=True, retries=3)
if not branch_check:
# Output is empty if branch does not exist
source_repo_branch = image_meta.config.content.source.git.branch.fallback
if source_repo_branch is Missing:
raise IOError(f'Unable to detect source repository branch for {image_meta.distgit_key}')
elif "alias" in image_meta.config.content.source:
alias = image_meta.config.content.source.alias
if alias not in runtime.group_config.sources:
raise IOError(f'Unable to find source alias {alias} for {image_meta.distgit_key}')
source_repo_url = runtime.group_config.sources[alias].url
source_repo_branch = runtime.group_config.sources[alias].branch.target
else:
# No upstream source, no PR to open
return None, None
return source_repo_url, source_repo_branch
@images_streams.group("prs", short_help="Manage ART equivalent PRs in upstream.")
def prs():
pass
@prs.command('list', short_help='List all open prs for upstream repos (requires GITHUB_TOKEN env var to be set.')
@pass_runtime
def images_upstreampulls(runtime):
runtime.initialize(clone_distgits=False, clone_source=False)
retdata = {}
upstreams = set()
github_client = Github(os.getenv(constants.GITHUB_TOKEN))
for image_meta in runtime.ordered_image_metas():
source_repo_url, source_repo_branch = _get_upstream_source(runtime, image_meta, skip_branch_check=True)
if not source_repo_url or 'github.com' not in source_repo_url:
runtime.logger.info('Skipping PR check since there is no configured github source URL')
continue
public_repo_url, public_branch = runtime.get_public_upstream(source_repo_url)
if not public_branch:
public_branch = source_repo_branch
_, org, repo_name = split_git_url(public_repo_url)
if public_repo_url in upstreams:
continue
upstreams.add(public_repo_url)
rateLimit = github_client.get_rate_limit()
if rateLimit.core.remaining < 1000:
time.sleep((rateLimit.core.reset - datetime.datetime.now()).seconds)
public_source_repo = github_client.get_repo(f'{org}/{repo_name}')
pulls = public_source_repo.get_pulls(state='open', sort='created')
for pr in pulls:
if pr.user.login == github_client.get_user().login and pr.base.ref == source_repo_branch:
for owners_email in image_meta.config['owners']:
retdata.setdefault(owners_email, {}).setdefault(public_repo_url, []).append(dict(pr_url=pr.html_url, created_at=pr.created_at))
print(yaml.dump(retdata, default_flow_style=False, width=10000))
def reconcile_jira_issues(runtime, pr_links: Dict[str, str], dry_run: bool):
"""
Ensures there is a Jira issue open for reconciliation PRs.
Args:
runtime: The doozer runtime
pr_links: a map of distgit_keys->pr_url to open reconciliation PRs
dry_run: If true, new desired jira issues would only be printed to the console.
"""
major, minor = runtime.get_major_minor_fields()
if (major == 4 and minor < 12) or major < 4:
# Only enabled for 4.12 and beyond at the moment.
return
new_issues: Dict[str, Issue] = dict()
existing_issues: Dict[str, Issue] = dict()
release_version = f'{major}.{minor}'
jira_client: JIRA = runtime.build_jira_client()
for distgit_key, pr_url in pr_links.items():
image_meta: ImageMetadata = runtime.image_map[distgit_key]
project, component = image_meta.get_jira_info()
summary = f"Update {release_version} {image_meta.name} image to be consistent with ART"
query = f'project={project} AND summary ~ "{summary}" AND statusCategory in ("To Do", "In Progress")'
open_issues = jira_client.search_issues(query)
if open_issues:
print(f'A JIRA issue is already open for {pr_url}: {open_issues[0]}')
existing_issues[distgit_key] = open_issues[0]
continue
description = f'''
Please review the following PR: {pr_url}
The PR has been automatically opened by ART (#aos-art) team automation and indicates
that the image(s) being used downstream for production builds are not consistent
with the images referenced in this component's github repository.
Differences in upstream and downstream builds impact the fidelity of your CI signal.
If you disagree with the content of this PR, please contact @release-artists
in #aos-art to discuss the discrepancy.
Closing this issue without addressing the difference will cause the issue to
be reopened automatically.
'''
fields = {
'project': {'key': project},
'issuetype': {'name': 'Bug'},
'versions': [{'name': release_version}], # Affects Version/s
'components': [{'name': component}],
'summary': summary,
'description': description
}
if not dry_run:
issue = jira_client.create_issue(
fields
)
new_issues[distgit_key] = issue
print(f'A JIRA issue has been opened for {pr_url}: {issue}')
else:
new_issues[distgit_key] = 'NEW!'
print(f'Would have created JIRA issue for {distgit_key} / {pr_url}:\n{fields}\n')
if new_issues:
print('Newly opened JIRA issues:')
for key, issue in new_issues.items():
print(f'{key}: {issue}')
if existing_issues:
print('Previously existing JIRA issues:')
for key, issue in existing_issues.items():
print(f'{key}: {issue}')
@prs.command('open', short_help='Open PRs against upstream component repos that have a FROM that differs from ART metadata.')
@click.option('--github-access-token', metavar='TOKEN', required=True, help='Github access token for user.')
@click.option('--bug', metavar='BZ#', required=False, default=None, help='Title with Bug #: prefix')
@click.option('--interstitial', metavar='SECONDS', default=120, required=False, help='Delay after each new PR to prevent flooding CI')
@click.option('--ignore-ci-master', default=False, is_flag=True, help='Do not consider what is in master branch when determining what branch to target')
@click.option('--ignore-missing-images', default=False, is_flag=True, help='Do not exit if an image is missing upstream.')
@click.option('--draft-prs', default=False, is_flag=True, help='Open PRs as draft PRs')
@click.option('--moist-run', default=False, is_flag=True, help='Do everything except opening the final PRs')
@click.option('--add-auto-labels', default=False, is_flag=True, help='Add auto_labels to PRs; unless running as openshift-bot, you probably lack the privilege to do so')
@click.option('--add-label', default=[], multiple=True, help='Add a label to all open PRs (new and existing) - Requires being openshift-bot')
@pass_runtime
def images_streams_prs(runtime, github_access_token, bug, interstitial, ignore_ci_master,
ignore_missing_images, draft_prs, moist_run, add_auto_labels, add_label):
runtime.initialize(clone_distgits=False, clone_source=False)
g = Github(login_or_token=github_access_token)
github_user = g.get_user()
major = runtime.group_config.vars['MAJOR']
minor = runtime.group_config.vars['MINOR']
interstitial = int(interstitial)
master_major, master_minor = extract_version_fields(what_is_in_master(), at_least=2)
if not ignore_ci_master and (major > master_major or minor > master_minor):
# ART building a release before it is in master. Too early to open PRs.
runtime.logger.warning(f'Target {major}.{minor} has not been in master yet (it is tracking {master_major}.{master_minor}); skipping PRs')
exit(0)
prs_in_master = (major == master_major and minor == master_minor) and not ignore_ci_master
pr_links = {} # map of distgit_key to PR URLs associated with updates
new_pr_links = {}
skipping_dgks = set() # If a distgit key is skipped, it children will see it in this list and skip themselves.
checked_upstream_images = set() # A PR will not be opened unless the upstream image exists; keep track of ones we have checked.
errors_raised = False # If failures are found when opening a PR, won't break the loop but will return an exit code to signal this event
for image_meta in runtime.ordered_image_metas():
dgk = image_meta.distgit_key
logger = image_meta.logger
logger.info('Analyzing image')
streams_pr_config = image_meta.config.content.source.ci_alignment.streams_prs
if streams_pr_config and streams_pr_config.enabled is not Missing and not streams_pr_config.enabled:
# Make sure this is an explicit False. Missing means the default or True.
logger.info('The image has alignment PRs disabled; ignoring')
continue
from_config = image_meta.config['from']
if not from_config:
logger.info('Skipping PR check since there is no configured .from')
continue
def check_if_upstream_image_exists(upstream_image):
if upstream_image not in checked_upstream_images:
# We don't know yet whether this image exists; perhaps a buildconfig is
# failing. Don't open PRs for images that don't yet exist.
try:
exectools.cmd_assert(f'oc image info {upstream_image}', retries=3)
except:
yellow_print(f'Unable to access upstream image {upstream_image} for {dgk}-- check whether buildconfigs are running successfully.')
if not ignore_missing_images:
raise
checked_upstream_images.add(upstream_image) # Don't check this image again since it is a little slow to do so.
desired_parents = []
# There are two methods to find the desired parents for upstream Dockerfiles.
# 1. We can analyze the image_metadata "from:" stanza and determine the upstream
# equivalents for streams and members. This is the most common flow of the code.
# 2. In rare circumstances, we need to circumvent this analysis and specify the
# desired upstreams equivalents directly in the image metadata. This is only
# required when the upstream Dockerfile uses something wholly different
# than the downstream build. Use this method by specifying:
# source.ci_alignment.streams_prs.from: [ list of full pullspecs ]
# We check for option 2 first
if streams_pr_config['from'] is not Missing:
desired_parents = streams_pr_config['from'].primitive() # This should be list; so we're done.
else:
builders = from_config.builder or []
for builder in builders:
upstream_image = resolve_upstream_from(runtime, builder)
if not upstream_image:
logger.warning(f'Unable to resolve upstream image for: {builder}')
break
check_if_upstream_image_exists(upstream_image)
desired_parents.append(upstream_image)
parent_upstream_image = resolve_upstream_from(runtime, from_config)
if len(desired_parents) != len(builders) or not parent_upstream_image:
logger.warning('Unable to find all ART equivalent upstream images for this image')
continue
desired_parents.append(parent_upstream_image)
desired_parent_digest = calc_parent_digest(desired_parents)
logger.info(f'Found desired FROM state of: {desired_parents} with digest: {desired_parent_digest}')
desired_ci_build_root_coordinate = None
if streams_pr_config.ci_build_root is not Missing:
desired_ci_build_root_image = resolve_upstream_from(runtime, streams_pr_config.ci_build_root)
check_if_upstream_image_exists(desired_ci_build_root_image)
# Split the pullspec into an openshift namespace, imagestream, and tag.
# e.g. registry.openshift.org:999/ocp/release:golang-1.16 => tag=golang-1.16, namespace=ocp, imagestream=release
pre_tag, tag = desired_ci_build_root_image.rsplit(':', 1)
_, namespace, imagestream = pre_tag.rsplit('/', 2)
# https://docs.ci.openshift.org/docs/architecture/ci-operator/#build-root-image
desired_ci_build_root_coordinate = {
'namespace': namespace,
'name': imagestream,
'tag': tag,
}
logger.info(f'Found desired build_root state of: {desired_ci_build_root_coordinate}')
source_repo_url, source_repo_branch = _get_upstream_source(runtime, image_meta)
if not source_repo_url or 'github.com' not in source_repo_url:
# No upstream to clone; no PRs to open
logger.info('Skipping PR check since there is no configured github source URL')
continue
public_repo_url, public_branch = runtime.get_public_upstream(source_repo_url)
if not public_branch:
public_branch = source_repo_branch
if (public_branch == 'master' or public_branch == 'main') and not prs_in_master:
# If a component is not using 'release-4.x' / 'openshift-4.x' branching mechanics,
# ART will be falling back to use master/main branch for the content of ALL
# releases. In this case, only open a reconciliation PR for when the current
# group matches what release CI is tracking in master.
logger.info(f'Skipping PR for {runtime.group} : {dgk} / {public_repo_url} since associated public branch is {public_branch} but CI is tracking {master_major}.{master_minor} in that branch.')
continue
# There are two standard upstream branching styles:
# release-4.x : CI fast-forwards from default branch (master or main) when appropriate
# openshift-4.x : Upstream team manages completely.
# For the former style, we may need to open the PRs against the default branch (master or main).
# For the latter style, always open directly against named branch
if public_branch.startswith('release-') and prs_in_master:
public_branches, _ = exectools.cmd_assert(f'git ls-remote --heads {public_repo_url}', strip=True)
public_branches = public_branches.splitlines()
priv_branches, _ = exectools.cmd_assert(f'git ls-remote --heads {source_repo_url}', strip=True)
priv_branches = priv_branches.splitlines()
if [bl for bl in public_branches if bl.endswith('/main')] and \
[bl for bl in priv_branches if bl.endswith('/main')]:
public_branch = 'main'
elif [bl for bl in public_branches if bl.endswith('/master')] and \
[bl for bl in priv_branches if bl.endswith('/master')]:
public_branch = 'master'
else:
# There are ways of determining default branch without using naming conventions, but as of today, we don't need it.
raise IOError(f'Did not find master or main branch; unable to detect default branch: {public_branches}')
_, org, repo_name = split_git_url(public_repo_url)
public_source_repo = g.get_repo(f'{org}/{repo_name}')
try:
fork_repo_name = f'{github_user.login}/{repo_name}'
fork_repo = g.get_repo(fork_repo_name)
except UnknownObjectException:
# Repo doesn't exist; fork it
fork_repo = github_user.create_fork(public_source_repo)
fork_branch_name = f'art-consistency-{runtime.group_config.name}-{dgk}'
fork_branch_head = f'{github_user.login}:{fork_branch_name}'
fork_branch = None
try:
fork_branch = fork_repo.get_branch(fork_branch_name)
except UnknownObjectException:
# Doesn't presently exist and will need to be created
pass
except GithubException as ge:
# This API seems to return 404 instead of UnknownObjectException.
# So allow 404 to pass through as well.
if ge.status != 404:
raise
public_repo_url = convert_remote_git_to_ssh(public_repo_url)
clone_dir = os.path.join(runtime.working_dir, 'clones', dgk)
# Clone the private url to make the best possible use of our doozer_cache
runtime.git_clone(source_repo_url, clone_dir)
with Dir(clone_dir):
exectools.cmd_assert(f'git remote add public {public_repo_url}')
exectools.cmd_assert(f'git remote add fork {convert_remote_git_to_ssh(fork_repo.git_url)}')
exectools.cmd_assert('git fetch --all', retries=3)
# The path to the Dockerfile in the target branch
if image_meta.config.content.source.dockerfile is not Missing:
# Be aware that this attribute sometimes contains path elements too.
dockerfile_name = image_meta.config.content.source.dockerfile
else:
dockerfile_name = "Dockerfile"
df_path = Dir.getpath()
if image_meta.config.content.source.path:
dockerfile_name = os.path.join(image_meta.config.content.source.path, dockerfile_name)
df_path = df_path.joinpath(dockerfile_name).resolve()
ci_operator_config_path = Dir.getpath().joinpath('.ci-operator.yaml').resolve() # https://docs.ci.openshift.org/docs/architecture/ci-operator/#build-root-image
assignee = None
try:
root_owners_path = Dir.getpath().joinpath('OWNERS')
if root_owners_path.exists():
parsed_owners = yaml.safe_load(root_owners_path.read_text())
if 'approvers' in parsed_owners and len(parsed_owners['approvers']) > 0:
assignee = random.choice(parsed_owners['approvers'])
except Exception as owners_e:
yellow_print(f'Error finding assignee in OWNERS for {public_repo_url}: {owners_e}')
fork_ci_build_root_coordinate = None
fork_branch_df_digest = None # digest of dockerfile image names
if fork_branch:
# There is already an ART reconciliation branch. Our fork branch might be up-to-date
# OR behind the times (e.g. if someone committed changes to the upstream Dockerfile).
# Let's check.
# If there is already an art reconciliation branch, get an MD5
# of the FROM images in the Dockerfile in that branch.
exectools.cmd_assert(f'git checkout fork/{fork_branch_name}')
if df_path.exists():
fork_branch_df_digest = compute_dockerfile_digest(df_path)
else:
# It is possible someone has moved the Dockerfile around since we
# made the fork.
fork_branch_df_digest = 'DOCKERFILE_NOT_FOUND'
if ci_operator_config_path.exists():
fork_ci_operator_config = yaml.safe_load(ci_operator_config_path.read_text(encoding='utf-8')) # Read in content from fork
fork_ci_build_root_coordinate = fork_ci_operator_config.get('build_root_image', None)
# Now change over to the target branch in the actual public repo
exectools.cmd_assert(f'git checkout public/{public_branch}')
try:
source_branch_parent_digest, source_branch_parents = extract_parent_digest(df_path)
except FileNotFoundError:
logger.error('%s not found in branch public/%s', df_path, public_branch)
errors_raised = True
continue
source_branch_ci_build_root_coordinate = None
if ci_operator_config_path.exists():
source_branch_ci_operator_config = yaml.safe_load(ci_operator_config_path.read_text(encoding='utf-8')) # Read in content from public source
source_branch_ci_build_root_coordinate = source_branch_ci_operator_config.get('build_root_image', None)
public_branch_commit, _ = exectools.cmd_assert('git rev-parse HEAD', strip=True)
logger.info(f'''
Desired parents: {desired_parents} ({desired_parent_digest})
Desired build_root (in .ci-operator.yaml): {desired_ci_build_root_coordinate}
Source parents: {source_branch_parents} ({source_branch_parent_digest})
Source build_root (in .ci-operator.yaml): {source_branch_ci_build_root_coordinate}
Fork build_root (in .ci-operator.yaml): {fork_ci_build_root_coordinate}
''')
if desired_parent_digest == source_branch_parent_digest and (desired_ci_build_root_coordinate is None or desired_ci_build_root_coordinate == source_branch_ci_build_root_coordinate):
green_print('Desired digest and source digest match; desired build_root unset OR coordinates match; Upstream is in a good state')
if fork_branch:
for pr in list(public_source_repo.get_pulls(state='open', head=fork_branch_head)):
if moist_run:
yellow_print(f'Would have closed existing PR: {pr.html_url}')
else:
yellow_print(f'Closing unnecessary PR: {pr.html_url}')
pr.edit(state='closed')
continue
cardinality_mismatch = False
if len(desired_parents) != len(source_branch_parents):
# The number of FROM statements in the ART metadata does not match the number
# of FROM statements in the upstream Dockerfile.
cardinality_mismatch = True
yellow_print(f'Upstream dockerfile does not match desired state in {public_repo_url}/blob/{public_branch}/{dockerfile_name}')
first_commit_line = f"Updating {image_meta.name} images to be consistent with ART"
reconcile_url = f'{convert_remote_git_to_https(runtime.gitdata.origin_url)}/tree/{runtime.gitdata.commit_hash}/images/{os.path.basename(image_meta.config_filename)}'
reconcile_info = f"Reconciling with {reconcile_url}"
diff_text = None
# Three possible states at this point:
# 1. No fork branch exists
# 2. It does exist, but is out of date (e.g. metadata has changed or upstream Dockerfile has changed)
# 3. It does exist, and is exactly how we want it.
# Let's create a local branch that will contain the Dockerfile/.ci-operator.yaml in the state we desire.
work_branch_name = '__mod'
exectools.cmd_assert(f'git checkout public/{public_branch}')
exectools.cmd_assert(f'git checkout -b {work_branch_name}')
with df_path.open(mode='r+') as handle:
dfp = DockerfileParser(cache_content=True, fileobj=io.BytesIO())
dfp.content = handle.read()
handle.truncate(0)
handle.seek(0)
if not cardinality_mismatch:
dfp.parent_images = desired_parents
else:
handle.writelines([
'# URGENT! ART metadata configuration has a different number of FROMs\n',
'# than this Dockerfile. ART will be unable to build your component or\n',
'# reconcile this Dockerfile until that disparity is addressed.\n'
])
handle.write(dfp.content)
exectools.cmd_assert(f'git add {str(df_path)}')
if desired_ci_build_root_coordinate:
if ci_operator_config_path.exists():
ci_operator_config = yaml.safe_load(ci_operator_config_path.read_text(encoding='utf-8'))
else:
ci_operator_config = {}