"""Bootstrap once, deliver source and hand route ownership to the selected Argo apps.""" import hashlib import json import os import sys import subprocess import yaml import optional_services from .common import ANSIBLE, ROOT, run from themelio_dx import compat as themelio_compat from . import storage from platform_gitops import lease, model, nodes, reconcile, source from platform_gitops.kubernetes import Cluster BOOTSTRAP_TASK = 'Maintain bootstrap-owned project, transport, scoped cache and route rights' def bootstrap_files(): """The files the Argo role applies, in its order, so the prepared dev-box set never falls behind it.""" tasks = yaml.safe_load((ROOT / 'devops/ansible/roles/argocd/tasks/main.yml').read_text()) return tuple(next(task['loop'] for task in tasks if task.get('name') == BOOTSTRAP_TASK)) def preflight(): model.verify_chart() source.commit(ROOT) def bootstrap_resources(resources, disabled_namespaces): """Remove an optional service's namespace, grants and project destinations.""" disabled = set(disabled_namespaces) selected = [] for resource in filter(None, resources): metadata = resource.get('metadata', {}) if metadata.get('namespace') in disabled: continue if resource.get('kind') == 'Namespace' and metadata.get('name') in disabled: continue if resource.get('kind') == 'AppProject': destinations = resource.get('spec', {}).get('destinations', []) resource['spec']['destinations'] = [value for value in destinations if value.get('namespace') not in disabled] selected.append(resource) return selected def prepare_bootstrap(target, disabled_namespaces): source = ROOT / 'gitops/manifests/argocd/bootstrap' destination = target.directory / 'argocd-bootstrap' destination.mkdir(mode=0o700, exist_ok=True) for name in bootstrap_files(): resources = yaml.safe_load_all((source / name).read_text()) target.write(destination / name, {'apiVersion': 'v1', 'kind': 'List', 'items': bootstrap_resources(resources, disabled_namespaces)}) return destination def bootstrap(target): storage.require_collection() target.assert_cluster() preflight() selected = set(themelio_compat.profiles(target)) enabled_namespaces = sorted(service['namespace'] for service in optional_services.SERVICES if service['id'] in selected and service['project'] == 'services') disabled_namespaces = {service['namespace'] for service in optional_services.SERVICES if service['id'] not in selected} options = {'argocd_bootstrap_source': str(prepare_bootstrap(target, disabled_namespaces)), 'argocd_optional_namespaces': enabled_namespaces, 'argocd_kserve_enabled': 'kserve' in selected} connected = target.settings.get('lifecycle', {}).get('k3sMode') == 'connect' if connected: # Existing clusters keep their kube-proxy policy and other applications' NodePorts. resources = list(yaml.safe_load_all((ROOT / 'gitops/manifests/argocd/bootstrap/build.yaml').read_text())) for resource in resources: if resource.get('kind') == 'Service': resource['spec']['type'] = 'ClusterIP' for port in resource['spec']['ports']: port.pop('nodePort', None) path = target.directory / 'build-bootstrap.json' target.write(path, {'apiVersion': 'v1', 'kind': 'List', 'items': resources}) options['argocd_build_manifest'] = str(path) target.write(target.directory / 'argocd-inventory.json', {'all': {'hosts': {'controller': { **options, 'ansible_connection': 'local', 'ansible_python_interpreter': sys.executable, 'argocd_architecture': target.settings.get('architecture', 'amd64'), # A dev box installs Argo CD with the devbox overlay, as its root Application does. 'argocd_source': str(ROOT), 'argocd_kubeconfig': str(target.kubeconfig), 'argocd_environment': 'devbox'}}}}) run('ansible-playbook', '-i', str(target.directory / 'argocd-inventory.json'), str(ANSIBLE / 'playbooks/argocd.yml')) if connected: from pharos_network import registry registry(target) def identity(cluster, administration_image=None): """Track private inputs and the image containing AAI's hook code/defaults.""" data = {name: (cluster.get('secret', name, 'pharos-aai') or {}).get('data', {}) for name in ('identity-overrides', 'pharos-aai-trust')} # Argo excludes completed hooks from normal resource comparison. Their image # must also change a regular resource before automated sync can rerun them. data['administration-image'] = administration_image return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest() def development_ca(target): """The public certificate the edge's GitLab certificate is issued by, for Source Control's trust.""" sys.path.insert(0, str(ROOT / 'dx/common')) from local_aai import up return up.certificate(target) def main(target): cluster = Cluster(target.kubeconfig) owner = cluster.owner() if not owner: raise ValueError('Argo bootstrap did not initialise ownership') lease.assert_free(cluster) route_inputs = json.loads((target.directory / 'route-inputs.json').read_text()) # The receipt names every image this delivery built or reused (decision 0045). receipt = json.loads((target.directory / 'receipt.json').read_text()) route_inputs['images'] = {name: image['reference'] for name, image in receipt['images'].items() if name in model.wanted(route_inputs['profiles'])} from local_aai.up import selection # Omission is the established local default. Returning to it also leaves # ownership records readable by checkouts from before provider selection. if selection(target)['mode'] == 'external': route_inputs['aaiMode'] = 'external' else: route_inputs.pop('aaiMode', None) if receipt.get('providerImages'): route_inputs['providerImages'] = receipt['providerImages'] else: route_inputs.pop('providerImages', None) if profile := target.settings.get('lifecycle', {}).get('imageProfile'): route_inputs['imageProfile'] = profile else: route_inputs.pop('imageProfile', None) route_inputs['identity'] = identity(cluster, route_inputs['images'].get('aai-administration')) # The dev box's edge answers on its node's own address: the workloads that call GitLab are admitted there only. route_inputs['edgeNodes'] = nodes.addresses(json.loads(cluster.run('get', 'nodes', '-o', 'json').stdout)) tick = themelio_compat.Tokens(target, trust=lambda: development_ca(target)) route_inputs = model.inputs(route_inputs) # The manifests, routes and images must come from one commit: a checkout that moved # after the images were built is refused before ownership or any Application changes. built = model.revision(receipt['commit']) moved = f'The checkout moved after the images were built from {built[:12]}; run make reconcile again' if source.commit(ROOT) != built: raise ValueError(moved) commit = source.deliver(cluster, ROOT, target.directory) if commit != built: raise ValueError(moved) previous = owner['data'] if (previous.get('routes') == 'argo' and previous.get('argocd') == 'argo' and previous.get('revision') == commit and json.loads(previous.get('inputs', '{}')) == route_inputs): reconcile.sync(cluster, commit, route_inputs, tick=tick) print('Argo ownership and all exact-revision health gates are unchanged at ' + commit) return # Reset removes namespace-local policies. Its fenced recovery may recreate them # through Argo while preserving every surviving route identity. An ordinary # first adoption from Helm still requires every route to exist. maintenance = json.loads(previous.get('maintenance', 'null')) operation = os.environ.get('PHAROS_DX_OPERATION') recovering = (previous['routes'] == 'restoring' and operation and maintenance and maintenance.get('operation') == operation) adopted = previous['routes'] == 'argo' or (previous['routes'] == 'adopting' and json.loads(previous.get('previous', '{}')).get('routes') == 'argo') before = reconcile.preview(cluster, route_inputs, target.directory, allow_new=bool(adopted or recovering)) backup = target.directory / 'routes-before-adoption.json' if previous['routes'] == 'ansible' and not backup.exists(): original_values = json.loads(subprocess.check_output(['helm', '--kubeconfig', str(target.kubeconfig), 'get', 'values', 'pharos-platform-routes', '-n', 'istio-system', '-o', 'json'], text=True)) target.write(backup, original_values) # Save the prior revision/inputs once per adoption attempt for deterministic # rollback. Every change uses the record's Kubernetes resourceVersion. changes = {'routes': 'adopting', 'argocd': 'adopting', 'revision': commit, 'inputs': json.dumps(route_inputs, sort_keys=True)} if previous['routes'] in {'ansible', 'argo'}: changes['previous'] = json.dumps({key: previous[key] for key in ('routes', 'argocd', 'revision', 'inputs') if key in previous}, sort_keys=True) cluster.write_owner(changes) names = reconcile.sync(cluster, commit, route_inputs, tick=tick) after = reconcile.identities(cluster, reconcile.routes(route_inputs)) if not reconcile.preserved(before, after): raise ValueError('Route identity changed during adoption; ownership remains fenced') cluster.write_owner({'routes': 'argo', 'argocd': 'argo'}) target.write(target.directory / 'argocd.json', {'revision': commit, 'applications': names, 'route_uids': after}) print('Argo applications are Synced and Healthy at delivered revision ' + commit)