diff --git a/.vscode/settings.json b/.vscode/settings.json index f821985..f77f368 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,28 @@ { "cSpell.words": [ "appender", + "asctime", + "basedirt", + "cachedir", + "devel", + "fastapi", + "getenv", + "hashlib", + "hmac", + "httpx", + "isoformat", "kahadb", + "levelname", "logappender", + "pydantic", + "pylint", + "pytest", "requestlogging", "springframework", + "startswith", "Supress", - "trapperkeeper" + "trapperkeeper", + "utcnow", + "uvicorn" ] } \ No newline at end of file diff --git a/manifests/main/config.pp b/manifests/main/config.pp index 234343e..69027ae 100644 --- a/manifests/main/config.pp +++ b/manifests/main/config.pp @@ -11,4 +11,9 @@ class puppet_cd::main::config ( if $pt_use_puppetdb == true { include puppet_cd::puppetdb::service } + + if $pt_use_r10k == true { + include puppet_cd::r10k::install + include puppet_cd::r10k::webhook + } } diff --git a/manifests/params.pp b/manifests/params.pp index 0c27473..6169c7f 100644 --- a/manifests/params.pp +++ b/manifests/params.pp @@ -10,10 +10,10 @@ # @param [String] pt_agent_pkg the packages for agents to install # @param [String] pt_server_pkg the server packages to install # @param [Array] pt_db_pkg the packages for puppetdb +# @param [Array] pt_r10k_pkg the packages for r10k to install # @param [String] pt_no_ssl_port non-ssl port number for puppetdb # @param [String] pt_ssl_port ssl port for puppetdb # @param [Boolean] pt_use_ssl_only whether to use ssl only. -# @param [Boolean] pt_manage_user whether to manage the puppet user # @param [String] pt_user the puppet user # @param [String] pt_user_comment the user comment # @param [String] pt_user_home the user home @@ -77,6 +77,11 @@ # @param [Boolean] pt_enable_repl whether to allow puppetdb replication # @param [String] pt_repl_port the replication port # @param [String] pt_repl_host the replication host +# @param [Boolean] pt_use_r10k whether to use r10k service +# @param [Boolean] pt_use_r10k_webhook whether to use r10k webhook service +# @param [String] pt_r10k_remote the remote url for the r10k control repo +# @param [Boolean] pt_r10k_prefix the r10k prefix. defaults to false +# @param [String] pt_r10k_basedir the base directory for r10k.yaml ############################################################################### class puppet_cd::params ( @@ -89,6 +94,7 @@ class puppet_cd::params ( String $pt_agent_pkg = 'puppet-agent', String $pt_server_pkg = 'puppetserver', Array $pt_db_pkg = ['puppetdb','puppetdb-termini'], + Array $pt_r10k_pkg = ['ruby','ruby-devel'], # user settings ## puppet user @@ -132,7 +138,7 @@ class puppet_cd::params ( String $pt_storeconfigs_backend = 'puppetdb', String $pt_parser = 'current', Boolean $pt_cert_revocation = true, -## puppetdb + ## puppetdb Boolean $pt_use_puppetdb = false, String $pt_logging_max_file_size = '200MB', String $pt_logging_max_history = '90', @@ -160,8 +166,19 @@ class puppet_cd::params ( String $pt_repl_port = '8082', String $pt_repl_host = '127.0.0.1', +# r10k + Boolean $pt_use_r10k = false, + Boolean $pt_use_r10k_webhook = false, + String $pt_r10k_remote = 'git@gitlab.example.net/repo.git', + Boolean $pt_r10k_prefix = false, + String $pt_r10k_basedir = '/etc/puppetlabs/code/environments', + ) { - $fqdn = $facts['networking']['fqdn'] +# facts + $fqdn = $facts['networking']['fqdn'] + $domain = $facts['networking']['domain'] + $os_name = $facts['os']['name'] + $os_release = $facts['os']['release']['major'] # directories ## puppet @@ -181,6 +198,8 @@ class puppet_cd::params ( $pt_puppetdb_ssl = "${pt_puppetdb_main}/ssl" $pt_puppetdb_log = '/var/log/puppetlabs/puppetdb' $pt_puppetdb_var_dir = '/opt/puppetlabs/server/data/puppetdb' +## r10k + $pt_r10k_dir = "${pt_main_dir}/r10k" # files ## puppet @@ -190,22 +209,25 @@ class puppet_cd::params ( $pt_hiera_config = "${pt_puppetdir}/hiera.yaml" ## puppetdb $pt_bootstrap_conf = "${pt_puppetdb_main}/bootstrap.cfg" - $pt_bootstrap_erb = 'cd_puppet/puppetdb/bootstrap.cfg.erb' + $pt_bootstrap_erb = 'puppet_cd/puppetdb/bootstrap.cfg.erb' $pt_puppetdb_access_log = "${pt_puppetdb_log}/puppetdb-access" $pt_request_logging_conf = "${pt_puppetdb_main}/request-logging.xml" - $pt_request_logging_erb = 'cd_puppet/puppetdb/request_logging.xml.erb' + $pt_request_logging_erb = 'puppet_cd/puppetdb/request_logging.xml.erb' $pt_logback_conf = "${pt_puppetdb_main}/logback.xml" - $pt_logback_erb = 'cd_puppet/puppetdb/logback.xml.erb' + $pt_logback_erb = 'puppet_cd/puppetdb/logback.xml.erb' $pt_puppetdb_config_ini = "${pt_puppetdb_conf_d}/config.ini" - $pt_puppetdb_config_erb = 'cd_puppet/puppetdb/config.ini.erb' + $pt_puppetdb_config_erb = 'puppet_cd/puppetdb/config.ini.erb' $pt_puppetdb_database_ini = "${pt_puppetdb_conf_d}/database.ini" - $pt_puppetdb_database_erb = 'cd_puppet/puppetdb/database.ini.erb' + $pt_puppetdb_database_erb = 'puppet_cd/puppetdb/database.ini.erb' $pt_puppetdb_jetty_ini = "${pt_puppetdb_conf_d}/jetty.ini" - $pt_puppetdb_jetty_erb = 'cd_puppet/puppetdb/jetty.ini.erb' + $pt_puppetdb_jetty_erb = 'puppet_cd/puppetdb/jetty.ini.erb' $pt_puppetdb_conf_file = "${pt_puppetdir}/puppetdb.conf" - $pt_puppetdb_conf_erb = 'cd_puppet/puppetdb/puppetdb.conf.erb' + $pt_puppetdb_conf_erb = 'puppet_cd/puppetdb/puppetdb.conf.erb' $pt_puppetdb_repl_ini = "${pt_puppetdb_conf_d}/repl.ini" - $pt_puppetdb_repl_erb = 'cd_puppet/puppetdb/repl.ini.erb' + $pt_puppetdb_repl_erb = 'puppet_cd/puppetdb/repl.ini.erb' +## r10k + $pt_r10k_file = "${pt_r10k_dir}/r10k.yaml" + $pt_r10k_erb = 'puppet_cd/r10k/r10k.yaml.erb' # service $pt_server_service = 'puppetserver' diff --git a/manifests/r10k/install.pp b/manifests/r10k/install.pp new file mode 100644 index 0000000..8ce10c7 --- /dev/null +++ b/manifests/r10k/install.pp @@ -0,0 +1,35 @@ +## puppet_cd::r10k::install.pp +# Module name: puppet_cd +# Author: Arne Teuke (arne_teuke@confdroid) +# @summary Class manages r10k installation for the puppet_cd module. +############################################################################### +class puppet_cd::r10k::install ( + +) inherits puppet_cd::params { + if ($pt_pm_fqdn == $fqdn) and ($pt_use_r10k == true) { + # install required packages + package { $pt_r10k_pkg: + ensure => $pt_pkg_ensure, + before => Package['r10k'], + } + + # install r10k via gem + package { 'r10k': + ensure => $pt_pkg_ensure, + provider => gem, + } + + # configure r10k.yaml + file { $pt_r10k_file: + ensure => file, + owner => 'root', + group => 'root', + mode => '0440', + selrange => s0, + selrole => object_r, + seltype => puppet_etc_t, + seluser => unconfined_u, + content => template($pt_r10k_erb), + } + } +} diff --git a/manifests/r10k/webhook.pp b/manifests/r10k/webhook.pp new file mode 100644 index 0000000..9ace78f --- /dev/null +++ b/manifests/r10k/webhook.pp @@ -0,0 +1,10 @@ +## puppet_cd::r10k::webhook.pp +# Module name: puppet_cd +# Author: Arne Teuke (arne_teuke@confdroid) +# @summary Class manages r10k webhook settings for the puppet_cd module. +############################################################################### +class puppet_cd::r10k::webhook ( + +) inherits puppet_cd::params { + +} diff --git a/templates/puppetdb/logback.xml.erb b/templates/puppetdb/logback.xml.erb old mode 100755 new mode 100644 diff --git a/templates/r10k/r10k.yaml.erb b/templates/r10k/r10k.yaml.erb new file mode 100644 index 0000000..9b0b77d --- /dev/null +++ b/templates/r10k/r10k.yaml.erb @@ -0,0 +1,7 @@ +:cachedir: /var/cache/r10k + +:sources: + :puppet: + remote: <%= @pt_r10k_remote %> + prefix: <&= @pt_r10k_prefix %> + basedir: '<%= @pt_r10k_basedir %>' diff --git a/templates/r10k/requirements.txt.erb b/templates/r10k/requirements.txt.erb new file mode 100644 index 0000000..8ca4007 --- /dev/null +++ b/templates/r10k/requirements.txt.erb @@ -0,0 +1,5 @@ +pytest==7.4.3 +pytest-cov==4.1.0 +httpx==0.25.2 +flake8==6.1.0 +pylint==3.0.1 \ No newline at end of file diff --git a/templates/r10k/webhook.py.erb b/templates/r10k/webhook.py.erb new file mode 100644 index 0000000..c5f7862 --- /dev/null +++ b/templates/r10k/webhook.py.erb @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Custom r10k Webhook Server for Puppet Control Repo +""" + +from datetime import datetime +import os +import subprocess +import logging +import hmac +import hashlib + +from fastapi import FastAPI, Request, HTTPException, BackgroundTasks +from fastapi.responses import JSONResponse +import uvicorn +from pydantic import BaseModel + + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/r10k-webhook.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + + +app = FastAPI(title="r10k Webhook Server") + + +class WebhookPayload(BaseModel): + """Data model for webhook payload""" + ref: str + project: dict + commits: list + + +def run_r10k_deploy() -> bool: + """Run r10k deploy command""" + try: + cmd = [ + '/usr/bin/r10k', 'deploy', + '-v', + '-c', '/etc/puppetlabs/r10k/r10k.conf' + ] + + logger.info("Starting r10k deploy...") + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True + ) + + logger.info("r10k deploy successful!") + logger.debug("r10k stdout: %s", result.stdout) + if result.stderr: + logger.warning("r10k stderr: %s", result.stderr) + return True + + except subprocess.CalledProcessError as e: + logger.error("r10k deploy failed: %s", e) + logger.error("stdout: %s", e.stdout) + logger.error("stderr: %s", e.stderr) + return False + except FileNotFoundError: + logger.error("r10k binary not found") + return False + except PermissionError: + logger.error("Permission denied running r10k") + return False + + +def validate_signature(payload: bytes, signature: str, secret: str) -> bool: + """Validate webhook signature""" + if not secret: + return True + + expected = hmac.new( + secret.encode(), + payload, + hashlib.sha256 + ).hexdigest() + + if signature.startswith('sha256='): + return hmac.compare_digest(signature, f'sha256={expected}') + + return hmac.compare_digest(signature, expected) + + +@app.post("/webhook") +async def webhook_handler( + request: Request, + background_tasks: BackgroundTasks +): + """Handle incoming webhook requests""" + + body = await request.body() + headers = dict(request.headers) + event_type = headers.get( + 'x-gitlab-event', + headers.get('x-github-event', 'unknown') + ) + signature = headers.get( + 'x-gitlab-token', + headers.get('x-hub-signature-256', '') + ) + + print( + f"DEBUG: Received webhook: event_type={event_type}, " + f"headers={headers}" + ) + logger.info( + "Received webhook: event_type=%s, headers=%s", + event_type, + headers + ) + + webhook_secret = os.getenv('R10K_WEBHOOK_SECRET', '') + is_valid = validate_signature(body, signature, webhook_secret) + if webhook_secret and not is_valid: + logger.warning("Invalid webhook signature") + raise HTTPException(status_code=403, detail="Invalid signature") + + try: + payload = await request.json() + ref = payload.get('ref', '') + branch = ref.split('/')[-1] if '/' in ref else ref + print( + f"DEBUG: Parsed payload: ref={ref}, " + f"branch={branch}" + ) + logger.info("Parsed payload: ref=%s, branch=%s", ref, branch) + + if branch not in ['main', 'master']: + logger.info("Ignoring non-main branch: %s", branch) + return JSONResponse({ + "status": "ignored", + "branch": branch + }) + + # Match GitLab event types explicitly + valid_events = [ + 'push hook', 'merge request hook', + 'push', 'Push', 'Push Hook' + ] + normalized_event = event_type.lower().strip() + print(f"DEBUG: Normalized event: {normalized_event}") + logger.info("Normalized event: %s", normalized_event) + if normalized_event in valid_events: + logger.info("Triggering r10k for %s on %s", event_type, branch) + background_tasks.add_task(run_r10k_deploy) + return JSONResponse({ + "status": "accepted", + "message": "r10k deploy triggered", + "timestamp": datetime.utcnow().isoformat(), + "branch": branch + }) + + logger.info("Ignoring event type: %s", event_type) + return JSONResponse({ + "status": "ignored", + "event": event_type + }) + + except ValueError as e: + logger.error("Webhook processing error: %s", e) + raise HTTPException( + status_code=400, + detail="Invalid payload" + ) from e + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + result = subprocess.run( + ['r10k', '--version'], + capture_output=True, + text=True, + check=True + ) + return { + "status": "healthy", + "r10k_version": result.stdout.strip() + } + + +if __name__ == "__main__": + uvicorn.run( + "webhook_server:app", + host="0.0.0.0", + port=8080, + log_level="info" + )