├── acceptance ├── tests │ └── .gitkeep ├── setup │ ├── common │ │ └── pre-suite │ │ │ └── .gitkeep │ ├── gem │ │ └── pre-suite │ │ │ └── .gitkeep │ ├── git │ │ └── pre-suite │ │ │ └── .gitkeep │ └── package │ │ └── pre-suite │ │ └── .gitkeep ├── config │ ├── gem │ │ └── options.rb │ ├── package │ │ └── options.rb │ └── git │ │ └── options.rb ├── .gitignore ├── Gemfile └── lib │ └── acceptance │ ├── ace_setup_helper.rb │ └── ace_command_helper.rb ├── spec ├── fixtures │ ├── modules │ │ └── .gitkeep │ ├── puppetdb │ │ ├── custom_source │ │ │ └── extensions.sql │ │ └── docker-entrypoint.sh │ ├── ssl │ │ ├── names.txt │ │ ├── README.md │ │ ├── ca.cfg │ │ ├── v3.ext │ │ ├── crl.pem │ │ ├── aceserver.csr │ │ ├── ca.pem │ │ ├── regen.sh │ │ ├── cert.pem │ │ ├── key.pem │ │ ├── ca_key.pem │ │ └── aceserver.cnf │ ├── conf │ │ ├── enc.sh │ │ ├── configure-enc.sh │ │ └── auth.conf │ └── api_server_configs │ │ ├── required-ace-server.conf │ │ └── global-ace-server.conf ├── unit │ ├── ace_spec.rb │ ├── ace │ │ ├── error_spec.rb │ │ ├── configurer_spec.rb │ │ ├── fork_util_spec.rb │ │ ├── file_mutex_spec.rb │ │ ├── config_spec.rb │ │ ├── plugin_cache_spec.rb │ │ └── transport_app_spec.rb │ └── puppet │ │ └── resource │ │ └── catalog │ │ └── certless_spec.rb ├── Dockerfile ├── spec_helper.rb ├── docker-compose.yml └── acceptance │ └── ace │ └── transport_app_spec.rb ├── CODEOWNERS ├── lib ├── ace │ ├── version.rb │ ├── file_mutex.rb │ ├── error.rb │ ├── configurer.rb │ ├── schemas │ │ ├── ace-run_task.json │ │ ├── task.json │ │ └── ace-execute_catalog.json │ ├── config.rb │ ├── fork_util.rb │ ├── plugin_cache.rb │ ├── puppet_util.rb │ └── transport_app.rb └── puppet │ └── indirector │ └── catalog │ └── certless.rb ├── .rspec ├── .gitignore ├── docker-compose.yml ├── config ├── local.conf ├── docker.conf └── transport_tasks_config.rb ├── Gemfile ├── Rakefile ├── Dockerfile ├── .github └── workflows │ └── spec.yml ├── README.md ├── agentless-catalog-executor.gemspec ├── .rubocop.yml ├── .dependency_decisions.yml ├── developer-docs ├── api.md └── docker.md ├── CHANGELOG.md └── LICENSE /acceptance/tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/fixtures/modules/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @puppetlabs/skeletor 2 | -------------------------------------------------------------------------------- /acceptance/setup/common/pre-suite/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acceptance/setup/gem/pre-suite/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acceptance/setup/git/pre-suite/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acceptance/setup/package/pre-suite/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/ace/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ACE 4 | VERSION = "1.2.4" 5 | end 6 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | --exclude-pattern 'spec/volumes/**/*' 5 | -------------------------------------------------------------------------------- /acceptance/config/gem/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | { 4 | pre_suite: [], 5 | load_path: './lib/acceptance' 6 | } 7 | -------------------------------------------------------------------------------- /spec/fixtures/puppetdb/custom_source/extensions.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | -------------------------------------------------------------------------------- /acceptance/config/package/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | { 4 | pre_suite: [], 5 | load_path: './lib/acceptance' 6 | } 7 | -------------------------------------------------------------------------------- /acceptance/config/git/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | { 4 | pre_suite: [], 5 | load_path: './lib/acceptance', 6 | ssh: { forward_agent: false } 7 | } 8 | -------------------------------------------------------------------------------- /acceptance/.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant 2 | .bundle 3 | Gemfile.lock 4 | local_options.rb 5 | log 6 | junit 7 | id_rsa-acceptance 8 | id_rsa-acceptance.pub 9 | merged_options.rb 10 | tmp 11 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/names.txt: -------------------------------------------------------------------------------- 1 | common-name=aceserver 2 | alt-names=localhost,aceserver,ace_aceserver_1,spec_puppetserver_1,ace_server,puppet_server,spec_aceserver_1,puppetdb,spec_puppetdb_1 3 | -------------------------------------------------------------------------------- /spec/unit/ace_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ACE do 6 | it "has a version number" do 7 | expect(ACE::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/conf/enc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # ensure that every node is forced to production 4 | # this is required for the strict_environment_mode/enforce_environment tests 5 | echo --- 6 | echo environment: production 7 | -------------------------------------------------------------------------------- /spec/fixtures/conf/configure-enc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /opt/puppetlabs/bin/puppet config set --section master node_terminus exec 4 | /opt/puppetlabs/bin/puppet config set --section master external_nodes /usr/local/bin/enc.sh 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.idea/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /Gemfile.lock 11 | /spec/volumes 12 | 13 | # rspec failure tracking 14 | .rspec_status 15 | \.DS_Store 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | aceserver: 4 | build: . 5 | ports: 6 | - "44633:44633" 7 | tty: true 8 | stdin_open: true 9 | networks: 10 | default: 11 | external: 12 | name: spec_default -------------------------------------------------------------------------------- /spec/fixtures/puppetdb/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | CERTNAME=pdb /ssl-setup.sh 6 | 7 | exec java $PUPPETDB_JAVA_ARGS -cp /puppetdb.jar \ 8 | clojure.main -m puppetlabs.puppetdb.core "$@" \ 9 | -c /etc/puppetlabs/puppetdb/conf.d/ 10 | -------------------------------------------------------------------------------- /spec/fixtures/api_server_configs/required-ace-server.conf: -------------------------------------------------------------------------------- 1 | ace-server: { 2 | ssl-cert: "spec/fixtures/ssl/cert.pem" 3 | ssl-key: "spec/fixtures/ssl/key.pem" 4 | ssl-ca-cert: "spec/fixtures/ssl/ca.pem" 5 | ssl-ca-crls: "spec/fixtures/ssl/crl.pem" 6 | puppet-server-uri: "https://localhost:8140" 7 | } 8 | -------------------------------------------------------------------------------- /config/local.conf: -------------------------------------------------------------------------------- 1 | ace-server: { 2 | ssl-cert: "spec/volumes/puppet/ssl/certs/aceserver.pem" 3 | ssl-key: "spec/volumes/puppet/ssl/private_keys/aceserver.pem" 4 | ssl-ca-cert: "spec/volumes/puppet/ssl/certs/ca.pem" 5 | ssl-ca-crls: "spec/volumes/puppet/ssl/ca/ca_crl.pem" 6 | puppet-server-uri: "https://0.0.0.0:8140" 7 | cache-dir: "tmp/" 8 | loglevel: "debug" 9 | } 10 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/README.md: -------------------------------------------------------------------------------- 1 | # Recreating the Certs 2 | 3 | The certs are used for local/docker testing and may need to be recreated if/when they have expired. At the time of writing this, the following command allows a new cert to be generated: 4 | 5 | ``` 6 | openssl x509 -req -extensions v3_req -days 3650 -sha256 -in aceserver.csr -CA ca.pem -CAkey ca_key.pem -CAcreateserial -out cert.pem -extfile aceserver.cnf 7 | ``` 8 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/ca.cfg: -------------------------------------------------------------------------------- 1 | # To enable the CA service, leave the following line uncommented 2 | #puppetlabs.services.ca.certificate-authority-service/certificate-authority-service 3 | # To disable the CA service, comment out the above line and uncomment the line below 4 | puppetlabs.services.ca.certificate-authority-disabled-service/certificate-authority-disabled-service 5 | puppetlabs.trapperkeeper.services.watcher.filesystem-watch-service/filesystem-watch-service 6 | -------------------------------------------------------------------------------- /spec/fixtures/api_server_configs/global-ace-server.conf: -------------------------------------------------------------------------------- 1 | ace-server: { 2 | host: 10.0.0.1 3 | port: 12345 4 | ssl-cert: "spec/fixtures/ssl/cert.pem" 5 | ssl-key: "spec/fixtures/ssl/key.pem" 6 | ssl-ca-cert: "spec/fixtures/ssl/ca.pem" 7 | ssl-ca-crls: "spec/fixtures/ssl/crl.pem" 8 | ssl-cipher-suites: [a] 9 | 10 | 11 | puppet-server-uri: "https://localhost:8140" 12 | loglevel: debug 13 | logfile: /var/log/global 14 | allowlist: [a] 15 | concurrency: 12 16 | } 17 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/v3.ext: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, keyEncipherment, dataEncipherment, keyAgreement 4 | extendedKeyUsage = serverAuth, clientAuth 5 | subjectAltName = @alt_names 6 | 7 | [ alt_names ] 8 | DNS.1 = localhost 9 | DNS.2 = aceserver 10 | DNS.3 = ace_aceserver_1 11 | DNS.4 = spec_puppetserver_1 12 | DNS.5 = ace_server 13 | DNS.6 = puppet_server 14 | DNS.7 = spec_aceserver_1 15 | DNS.8 = puppetdb 16 | DNS.9 = spec_puppetdb_1 17 | DNS.9 = 0.0.0.0 -------------------------------------------------------------------------------- /spec/fixtures/ssl/crl.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN X509 CRL----- 2 | MIIBUjA8MA0GCSqGSIb3DQEBCwUAMA0xCzAJBgNVBAMMAmNhFw0yMzAzMjAyMjQz 3 | MzRaFw0yNTEyMTQyMjQzMzRaMA0GCSqGSIb3DQEBCwUAA4IBAQDMBhCLGsjfnbjC 4 | /a9YG7PoJk/obciJJn7LlQAiLkgjlzRz7tDFPCp9nxN/WAfxljggHSBU+ntPcFSc 5 | 2Da4a8eKzORyQxZVsvfVOcPqDcu754k6SLgc/UQgP5Pztuupc/qdOFZENbIkt5cS 6 | rpZ6wUFzp/NQTD3iP4isYONYF5D0zGp1YcvYwH7DpDEPcUD3YFLs3zUHDq5lAiCR 7 | quvshpbzpz/+FmXqExVVvSRWRH4eMxcrF3YmC14c0WlERLGVJ43fkQ85/hnG+xc0 8 | ssSFc/8DIeEXlflVZh7Q4VufQixyMD5+eQX4N5wR+w4cN82GGVHiEfTlb/bZkGkn 9 | YS7xKayn 10 | -----END X509 CRL----- 11 | -------------------------------------------------------------------------------- /lib/ace/file_mutex.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'timeout' 4 | 5 | module ACE 6 | class FileMutex 7 | def initialize(lock_file) 8 | @lock_file = lock_file 9 | end 10 | 11 | def with_read_lock 12 | fh = File.open(@lock_file, File::CREAT) 13 | fh.flock(File::LOCK_SH) 14 | yield 15 | ensure 16 | fh.flock(File::LOCK_UN) 17 | fh.close 18 | end 19 | 20 | def with_write_lock 21 | fh = File.open(@lock_file, File::CREAT) 22 | fh.flock(File::LOCK_EX) 23 | yield 24 | ensure 25 | fh.flock(File::LOCK_UN) 26 | fh.close 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/unit/ace/error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/error' 5 | 6 | RSpec.describe ACE::Error do 7 | let(:instance) { 8 | described_class.new('failed', 'module/error_type') 9 | } 10 | 11 | it { expect(instance).to be_a_kind_of(RuntimeError) } 12 | 13 | it { expect(instance.msg).to eq('failed') } 14 | 15 | it 'returns the error as a hash' do 16 | expect(instance.to_h).to eq( 17 | 'kind' => 'module/error_type', 18 | 'msg' => 'failed', 19 | 'details' => {} 20 | ) 21 | end 22 | 23 | it 'returns the error as a json string' do 24 | expect(instance.to_json).to eq( 25 | '{"kind":"module/error_type","msg":"failed","details":{}}' 26 | ) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/Dockerfile: -------------------------------------------------------------------------------- 1 | # used for the puppetserver in order 2 | # for some modules to be installed and 3 | # to relevant environments for testing 4 | FROM puppet/puppetserver:6.14.1 5 | 6 | COPY ./fixtures/conf/auth.conf /etc/puppetlabs/puppetserver/conf.d/ 7 | COPY ./fixtures/conf/enc.sh /usr/local/bin/enc.sh 8 | COPY ./fixtures/conf/configure-enc.sh /docker-entrypoint.d/85-configure-enc.sh 9 | 10 | RUN puppet module install puppetlabs-panos --environment production && \ 11 | puppet module install puppetlabs-cisco_ios --environment production && \ 12 | puppet module install puppetlabs-test_device --environment production && \ 13 | cp -a $(puppet config print environmentpath)/production $(puppet config print environmentpath)/something_else && \ 14 | chmod +x /usr/local/bin/enc.sh 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | group :tests do 8 | gem 'codecov' 9 | gem 'license_finder' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.4.0') 10 | gem 'simplecov-console' 11 | gem 'webmock' 12 | end 13 | 14 | group :development do 15 | # gem 'bolt', git: 'https://github.com/puppetlabs/bolt', branch: 'master' 16 | # activesupport 7 bumped the minimum Ruby version to 2.7. We can remove this 17 | # once we're on that version. 18 | gem "activesupport", "~> 6.0" 19 | gem 'github_changelog_generator', '~> 1.14' 20 | gem 'pry-byebug' 21 | gem 'rubocop-rspec' 22 | end 23 | 24 | # Specify your gem's dependencies in agentless-catalog-executor.gemspec 25 | gemspec 26 | -------------------------------------------------------------------------------- /acceptance/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source ENV['GEM_SOURCE'] || "https://rubygems.org" 4 | 5 | def location_for(place, fake_version = nil) 6 | git_match = place.match(/^(git:[^#]*)#(.*)/) 7 | file_match = place.match(%r{^file://(.*)}) 8 | if git_match 9 | git, branch = git_match.captures 10 | [fake_version, { git: git, branch: branch, require: false }].compact 11 | elsif file_match 12 | file_path = file_match[1] 13 | ['>= 0', { path: File.expand_path(file_path), require: false }] 14 | else 15 | [place, { require: false }] 16 | end 17 | end 18 | 19 | gem "beaker", *location_for(ENV['BEAKER_VERSION'] || "~> 4.0") 20 | gem "beaker-hostgenerator", *location_for(ENV['BEAKER_HOSTGENERATOR_VERSION'] || "~> 1.0") 21 | 22 | if File.exist? "Gemfile.local" 23 | eval_gemfile "Gemfile.local" 24 | end 25 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/aceserver.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICEzCCAXwCAQAwFDESMBAGA1UEAxMJYWNlc2VydmVyMIGfMA0GCSqGSIb3DQEB 3 | AQUAA4GNADCBiQKBgQC9uxCK4BvImY5FfMmzYZz1Bm++cVklrYA/N0BFmcZt/i3g 4 | /RnSjao20j3lk0qqR1Op21tZXBeLyuDvMPea93SA8LoOO9UhUPyuIanFI3s3eaEi 5 | ZPAa0zDfr9y6M2DTQ18CuLMOe57wYS5ADr/w5R877U8PuqO9s9p1ROZnNmgF+QID 6 | AQABoIG+MIG7BgkqhkiG9w0BCQ4xga0wgaowCQYDVR0TBAIwADALBgNVHQ8EBAMC 7 | BeAwgY8GA1UdEQSBhzCBhIIJbG9jYWxob3N0gglhY2VzZXJ2ZXKCD2FjZV9hY2Vz 8 | ZXJ2ZXJfMYITc3BlY19wdXBwZXRzZXJ2ZXJfMYIKYWNlX3NlcnZlcoINcHVwcGV0 9 | X3NlcnZlcoIQc3BlY19hY2VzZXJ2ZXJfMYIIcHVwcGV0ZGKCD3NwZWNfcHVwcGV0 10 | ZGJfMTANBgkqhkiG9w0BAQsFAAOBgQAOl4sgiv4QrOD0LsWl60v1wElNf3ViaDFX 11 | 8nmNmXjtrr+158FwVNwGJQHgyTPXKIxH9ls6lvIG/0PB0UUf4huumOph9G3Axihy 12 | S6IWnZU+iahwM2EQdZOLft1YhCopSShnQBuQmBqA0RGtSb308FX1rCVxUQzLEZnZ 13 | R7Z9dc4Oxw== 14 | -----END CERTIFICATE REQUEST----- 15 | -------------------------------------------------------------------------------- /lib/ace/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ACE 4 | class Error < RuntimeError 5 | attr_reader :kind, :details, :issue_code, :error_code 6 | 7 | def initialize(msg, kind, details = nil, issue_code = nil) 8 | super(msg) 9 | @kind = kind 10 | @issue_code = issue_code 11 | @details = details || {} 12 | @error_code ||= 1 13 | end 14 | 15 | def msg 16 | message 17 | end 18 | 19 | def to_h 20 | Error.to_h(msg, kind, details, issue_code) 21 | end 22 | 23 | def to_json(opts = nil) 24 | to_h.to_json(opts) 25 | end 26 | 27 | def self.to_h(message, kind, details = nil, issue_code = nil) 28 | h = { 'kind' => kind, 29 | 'msg' => message, 30 | 'details' => details } 31 | h['issue_code'] = issue_code if issue_code 32 | h 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /config/docker.conf: -------------------------------------------------------------------------------- 1 | ace-server: { 2 | # being explicit about the pathing within the container 3 | # although it is ran from within the /ace directory 4 | # we feel it is best to distinguish that this is a 5 | # docker configuration file and not a `local` 6 | ssl-cert: "/ace/spec/volumes/puppet/ssl/certs/aceserver.pem" 7 | ssl-key: "/ace/spec/volumes/puppet/ssl/private_keys/aceserver.pem" 8 | ssl-ca-cert: "/ace/spec/volumes/puppet/ssl/certs/ca.pem" 9 | ssl-ca-crls: "/ace/spec/volumes/puppet/ssl/ca/ca_crl.pem" 10 | # the dns of puppet within the docker network 11 | # is the same as spec_puppet_1 locally as the 12 | # hostname is `puppet` within the docker network 13 | puppet-server-uri: "https://puppet:8140" 14 | loglevel: debug 15 | # host to run the ACE service on, i.e. 16 | # 0.0.0.0 within the container 17 | host: "0.0.0.0" 18 | } 19 | -------------------------------------------------------------------------------- /acceptance/lib/acceptance/ace_setup_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Acceptance 4 | module AceSetupHelper 5 | def ssh_user 6 | ENV['SSH_USER'] || 'root' 7 | end 8 | 9 | def ssh_password 10 | ENV['SSH_PASSWORD'] || 'bolt_secret_password' 11 | end 12 | 13 | def winrm_user 14 | ENV['WINRM_USER'] || 'Administrator' 15 | end 16 | 17 | def winrm_password 18 | ENV['WINRM_PASSWORD'] || 'bolt_secret_password' 19 | end 20 | 21 | def gem_version 22 | ENV['GEM_VERSION'] || '> 0.1.0' 23 | end 24 | 25 | def gem_source 26 | ENV['GEM_SOURCE'] || 'https://rubygems.org' 27 | end 28 | 29 | def git_server 30 | ENV['GIT_SERVER'] || 'https://github.com' 31 | end 32 | 33 | def git_fork 34 | ENV['GIT_FORK'] || 'puppetlabs/bolt' 35 | end 36 | 37 | def git_branch 38 | ENV['GIT_BRANCH'] || 'master' 39 | end 40 | 41 | def git_sha 42 | ENV['GIT_SHA'] || '' 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ace/configurer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'puppet' 4 | # NOTE: Changes in puppet code loading results in simply requiring `puppet/configurer` no longer 5 | # possible. The following requires can make ruby load, however selectively loading code from puppet 6 | # will likely lead to issues in the future. Instead, just load puppet here. 7 | # require 'puppet/util/autoload' 8 | # require 'puppet/parser/compiler' 9 | # require 'puppet/parser' 10 | require 'puppet/configurer' 11 | 12 | module ACE 13 | class Configurer < Puppet::Configurer 14 | # override the configurer to return the facts 15 | # related to the transport and the trusted 16 | # facts which is passed to the configurer.run 17 | def get_facts(options) 18 | transport_facts = Puppet::Node::Facts.indirection.find(Puppet[:certname], 19 | environment: Puppet[:environment]).values 20 | trusted_facts = options[:trusted_facts] 21 | { transport_facts: transport_facts, trusted_facts: trusted_facts } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ace/schemas/ace-run_task.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "ACE run_task request", 4 | "description": "POST /run_task request schema for ACE", 5 | "type": "object", 6 | "properties": { 7 | "target": { 8 | "type": "object", 9 | "description": "Contains the Transport schema to connect to the remote target", 10 | "properties": { 11 | "remote-transport": { 12 | "type": "string", 13 | "description": "The name of the transport being used" 14 | }, 15 | "run-on": { 16 | "type": "string", 17 | "description": "" 18 | } 19 | }, 20 | "additionalProperties": true 21 | }, 22 | "task": { "$ref": "file:task"}, 23 | "parameters": { 24 | "type": "object", 25 | "description": "JSON formatted parameters to be provided to task" 26 | }, 27 | "timeout": { 28 | "type": "integer", 29 | "description": "Number of seconds to wait before abandoning the task execution on the tartet." 30 | } 31 | }, 32 | "required": ["target", "task"] 33 | } -------------------------------------------------------------------------------- /spec/fixtures/ssl/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+zCCAeOgAwIBAgIUbKXkpPwf5i0fw82Yzw5RABZa3PYwDQYJKoZIhvcNAQEL 3 | BQAwDTELMAkGA1UEAwwCY2EwHhcNMjMwMzIwMjI0MzM0WhcNMjUxMjE0MjI0MzM0 4 | WjANMQswCQYDVQQDDAJjYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 5 | APnJiSfPHmek19vDpiFGeiYvBG5Xy7i4hE/cBL3ePCiANTiEB46LTu8j9wpBl6Fb 6 | Y/xEuowe8X9xybP//qbB1p5DNJ9lY/ugiHoYFrG0mAnKlXKPGhOkP6LdocrnezxK 7 | Na277S6OQ9eujwPcekDzcmn+ABUJQKRCbaMnaUEzUbnsCATmg+UiH31XGtIkgrc5 8 | Ez+clFd8AeJHt9ifPy1UnbQQ14o9YC2pE8NLPWyR2Hzzva52oUXIC29SBJ/biLO/ 9 | xf4ZExOWYd1rXqItPjjeoHHWk+R0DQTi9CDQOQ32B5/PilDvmbRYqDYU4vypdpcm 10 | 2sc2374O8sfx0roLR2Wa9ZcCAwEAAaNTMFEwHQYDVR0OBBYEFMJjn9BQeFR0hK1C 11 | 4Xllfb1WcqWuMB8GA1UdIwQYMBaAFMJjn9BQeFR0hK1C4Xllfb1WcqWuMA8GA1Ud 12 | EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJhN2piqcQWo13uthFGEQVKJ 13 | otR2lkBH5Ex0EHjdctRD4i71r1FvIDGIHoBUUE43bQ93BFbeJQb1/sVRzw6WEvym 14 | G4tFJpphlOFpWvWLkpOvEzqdEuUdvMS7rdJV2wN9j3riA9MAuyuQAjg8iq91hwZi 15 | oNA/LajrMdl4BNbH7mNGKR3Z+jyF+MI40H2XTbee05/gn/g7LHk6W4IEVg/yQaQh 16 | hCyQO/X4y5IDnpigCwRAAhBlT16OKaAEapxTTms9pVgG/X5vR7mk/dMBsMZ4JLBd 17 | KIv6wQzKa4My2Dq2vNH+zPk0/+BRSVUXg3+IYZrmeQ8+ATab9AQ86Y8rqn8+GYE= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /spec/unit/ace/configurer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/configurer' 5 | require 'puppet/configurer' 6 | 7 | RSpec.describe ACE::Configurer do 8 | let(:configurer) { described_class.new } 9 | let(:indirection) { instance_double(Puppet::Indirector::Indirection, 'indirection') } 10 | let(:node_facts) { instance_double(Puppet::Node::Facts, 'node_facts') } 11 | let(:options) do 12 | { 13 | trusted_facts: { 14 | 'foo' => 'cat' 15 | } 16 | } 17 | end 18 | let(:transport_facts) do 19 | { 20 | 'cat' => 'dog' 21 | } 22 | end 23 | 24 | describe "#get_facts" do 25 | before do 26 | allow(Puppet::Node::Facts).to receive(:indirection).and_return(indirection) 27 | allow(indirection).to receive(:find).and_return(node_facts) 28 | allow(node_facts).to receive(:values).and_return(transport_facts) 29 | end 30 | 31 | it 'returns the trusted facts' do 32 | expect(configurer.get_facts(options)).to eq(transport_facts: { "cat" => 'dog' }, 33 | trusted_facts: { "foo" => "cat" }) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require 'rubocop/rake_task' 5 | 6 | RuboCop::RakeTask.new(:rubocop) do |t| 7 | t.options = ['--display-cop-names'] 8 | end 9 | 10 | task default: %i[rubocop spec] 11 | 12 | #### RSPEC #### 13 | require 'rspec/core/rake_task' 14 | 15 | RSpec::Core::RakeTask.new(:spec) 16 | 17 | namespace :spec do 18 | desc 'Run RSpec code examples with coverage collection' 19 | task :coverage do 20 | ENV['COVERAGE'] = 'yes' 21 | Rake::Task['spec'].execute 22 | end 23 | end 24 | 25 | #### CHANGELOG #### 26 | begin 27 | require 'github_changelog_generator/task' 28 | GitHubChangelogGenerator::RakeTask.new :changelog do |config| 29 | require 'ace/version' 30 | config.future_release = "v#{ACE::VERSION}" 31 | config.header = "# Changelog\n\n" \ 32 | "All significant changes to this repo will be summarized in this file.\n" 33 | # config.include_labels = %w[enhancement bug] 34 | config.user = 'puppetlabs' 35 | config.project = 'ace' 36 | end 37 | rescue LoadError 38 | desc 'Install github_changelog_generator to get access to automatic changelog generation' 39 | task :changelog do 40 | raise 'Install github_changelog_generator to get access to automatic changelog generation' 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/regen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | # generate the key for the certificate authority 6 | openssl genrsa -aes128 -out ca_key.pem -passout pass:password 2048 7 | 8 | # make a certificate signing request but output a certificate signed by the CA 9 | # key instead of a csr, include the common name of 'ca' 10 | openssl req -new -key ca_key.pem -x509 -days 1000 -out ca.pem -subj /CN=ca -passin pass:password 11 | 12 | # create a blank database file for the CA config 13 | touch inventory 14 | 15 | # create a CA emulator with a blank database and use that to generate a CRL 16 | openssl ca -name ca -config <(echo database = inventory) -keyfile ca_key.pem -passin pass:password -cert ca.pem -md sha256 -gencrl -crldays 1000 -out crl.pem 17 | 18 | # make a new CSR, also generating a private key with the subject name of 'boltserver' 19 | openssl req -new -sha256 -nodes -out cert.csr -newkey rsa:2048 -keyout key.pem -subj /CN=boltserver 20 | 21 | # generate a cert using the CSR generated above signed by CA key from the first operation 22 | # and including the certificate extensions contained in the v3.ext file 23 | openssl x509 -req -in cert.csr -CA ca.pem -CAkey ca_key.pem -CAcreateserial -out cert.pem -passin pass:password -days 999 -sha256 -extfile v3.ext 24 | 25 | rm cert.csr ca.srl inventory -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Install gems 2 | FROM puppet/puppet-agent-alpine:6.4.2 as build 3 | 4 | RUN \ 5 | apk --no-cache add build-base ruby-dev ruby-bundler ruby-json ruby-bigdecimal git openssl-dev linux-headers && \ 6 | echo 'gem: --no-document' > /etc/gemrc && \ 7 | bundle config --global silence_root_warning 1 8 | 9 | RUN mkdir /ace 10 | # Gemfile requires gemspec which requires ace/version which requires ace 11 | ADD . /ace 12 | WORKDIR /ace 13 | RUN rm -f Gemfile.lock 14 | RUN bundle install --no-cache --path vendor/bundle 15 | 16 | # Final image 17 | FROM build 18 | ARG ace_version=no-version 19 | LABEL org.label-schema.maintainer="Network Automation Team " \ 20 | org.label-schema.vendor="Puppet" \ 21 | org.label-schema.url="https://github.com/puppetlabs/ace" \ 22 | org.label-schema.name="Agentless Catalog Executor" \ 23 | org.label-schema.license="Apache-2.0" \ 24 | org.label-schema.version=${ace_version} \ 25 | org.label-schema.vcs-url="https://github.com/puppetlabs/ace" \ 26 | org.label-schema.dockerfile="/Dockerfile" 27 | 28 | RUN \ 29 | apk --no-cache add ruby openssl ruby-bundler ruby-json ruby-bigdecimal git 30 | 31 | COPY --from=build /ace /ace 32 | WORKDIR /ace 33 | 34 | EXPOSE 44633 35 | ENV ACE_CONF /ace/config/docker.conf 36 | 37 | ENTRYPOINT bundle exec puma -C config/transport_tasks_config.rb -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require 'webmock/rspec' 5 | 6 | if ENV['COVERAGE'] == 'yes' 7 | require 'simplecov' 8 | require 'simplecov-console' 9 | require 'codecov' 10 | 11 | SimpleCov.formatters = [ 12 | SimpleCov::Formatter::HTMLFormatter, 13 | SimpleCov::Formatter::Console, 14 | SimpleCov::Formatter::Codecov 15 | ] 16 | SimpleCov.start do 17 | track_files 'lib/**/*.rb' 18 | 19 | add_filter 'lib/ace/version.rb' 20 | 21 | add_filter '/spec' 22 | 23 | # do not track vendored files 24 | add_filter '/vendor' 25 | add_filter '/.vendor' 26 | 27 | # do not track gitignored files 28 | # this adds about 4 seconds to the coverage check 29 | # this could definitely be optimized 30 | add_filter do |f| 31 | # system returns true if exit status is 0, which with git-check-ignore means file is ignored 32 | system("git check-ignore --quiet #{f.filename}") 33 | end 34 | end 35 | end 36 | 37 | RSpec.configure do |config| 38 | # Enable flags like --only-failures and --next-failure 39 | config.example_status_persistence_file_path = ".rspec_status" 40 | 41 | # Disable RSpec exposing methods globally on `Module` and `main` 42 | config.disable_monkey_patching! 43 | 44 | config.expect_with :rspec do |c| 45 | c.syntax = :expect 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDlDCCAnygAwIBAgIUW2+OR55UoG54LJ09zzC0qHE1ndcwDQYJKoZIhvcNAQEL 3 | BQAwDTELMAkGA1UEAwwCY2EwHhcNMjMwMzIwMjI0MzM0WhcNMjUxMjEzMjI0MzM0 4 | WjAVMRMwEQYDVQQDDApib2x0c2VydmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 5 | MIIBCgKCAQEAuyTBySDmcyCXfsaAnMG9xewRjuWhCBNTMHb5+xlpg1d1NztiNKA+ 6 | vKLaMoptxPRMGyhfIFHH50a/wCysOPQxijuaUIhg12SqL/Qrp9KDr/Apq7BwE3/T 7 | 0LBEP8O5eBWi9blF4na1r3aUEqp+NbawvvruvUsF5b+xAeaw0gWfgefV4OYvyAvx 8 | hOeWjAdh58iIoYdDqSrEe+8uuKkf+KfmSH4khf6H01BziYZKpRuwdf+nBd8EVHbJ 9 | n7AoHxYrC44H7Y22SrORwVDFW04Yh6sJD7F2JbvIAfEWCcBhwuVoEPZ5fbmdhVen 10 | Tjdkj9pNVguWiZFmb0BP+zPDUJQIRjsfaQIDAQABo4HjMIHgMB8GA1UdIwQYMBaA 11 | FMJjn9BQeFR0hK1C4Xllfb1WcqWuMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgO4MB0G 12 | A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjCBhQYDVR0RBH4wfIIJbG9jYWxo 13 | b3N0gglhY2VzZXJ2ZXKCD2FjZV9hY2VzZXJ2ZXJfMYITc3BlY19wdXBwZXRzZXJ2 14 | ZXJfMYIKYWNlX3NlcnZlcoINcHVwcGV0X3NlcnZlcoIQc3BlY19hY2VzZXJ2ZXJf 15 | MYIIcHVwcGV0ZGKCBzAuMC4wLjAwDQYJKoZIhvcNAQELBQADggEBADpZhfq+QvU4 16 | NJz+7ckvz+G/1pzjBjZESfRMYF9rsbec3pDSVh2YXW/+bKPkVcdmZk9F8nqaljoC 17 | qgNJzR3WYB3lm/FaFPiHisgR5xUTCEuWrsNqerzGbI8j4MweSl8xZTXo12DJRE9N 18 | 5gg937PPlpEbWZkdCEjCuv/C+g+97kA8b8hM5qrHG8eOw+0sZx3K2taP7i9NYEXO 19 | Brk2bGkBF91BHUmMQZXboCJtKQVOBsCTYhNcjkEFMRqWxTcO3NJQW7kCsn0pV3aw 20 | 1/I/78J7OPGbME8+lWF2WAEXFSbBtMYpHIKk8xS+ffCKpm2uN48dPoNcM5H525Wq 21 | juq01atB6ec= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /spec/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | puppet: 5 | hostname: puppet 6 | container_name: spec_puppet_1 7 | build: . 8 | ports: 9 | - 8140:8140 10 | environment: 11 | - DNS_ALT_NAMES=puppet,localhost,aceserver,ace_aceserver_1,spec_puppetserver_1,ace_server,puppet_server,spec_aceserver_1,puppetdb,spec_puppetdb_1,0.0.0.0 12 | - PUPPETDB_SERVER_URLS=https://puppetdb:8081 13 | - CA_ALLOW_SUBJECT_ALT_NAMES=true 14 | volumes: 15 | - ./volumes/puppet:/etc/puppetlabs/puppet/ 16 | - ./volumes/serverdata:/opt/puppetlabs/server/data/puppetserver/ 17 | 18 | postgres: 19 | container_name: spec_postgres_1 20 | environment: 21 | - POSTGRES_PASSWORD=puppetdb 22 | - POSTGRES_USER=puppetdb 23 | - POSTGRES_DB=puppetdb 24 | expose: 25 | - 5432 26 | image: postgres:9.6 27 | volumes: 28 | - ./volumes/puppetdb-postgres/data:/var/lib/postgresql/data/ 29 | - ./postgres-custom:/docker-entrypoint-initdb.d 30 | 31 | puppetdb: 32 | hostname: puppetdb 33 | container_name: spec_puppetdb_1 34 | image: puppet/puppetdb 35 | environment: 36 | - PUPPETDB_PASSWORD=puppetdb 37 | - PUPPETDB_USER=puppetdb 38 | ports: 39 | - 32775:8080 40 | - 32776:8081 41 | depends_on: 42 | - postgres 43 | - puppet 44 | volumes: 45 | - ./volumes/puppetdb/ssl:/etc/puppetlabs/puppet/ssl/ 46 | 47 | networks: 48 | default: 49 | -------------------------------------------------------------------------------- /.github/workflows/spec.yml: -------------------------------------------------------------------------------- 1 | name: ACE PR tests 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, edited, synchronize] 6 | 7 | jobs: 8 | ace_pr_tests: 9 | name: Ace PR tests 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | ruby: [2.7, 3.2] 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | - name: Setup Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby }} 21 | bundler-cache: true 22 | - name: Update gems 23 | run: bundle update 24 | - name: Setup tests 25 | shell: bash 26 | run: | 27 | docker-compose -f spec/docker-compose.yml build --parallel 28 | docker-compose -f spec/docker-compose.yml up -d 29 | docker ps -a 30 | i="0"; while true; do echo Checking...; echo $(docker logs spec_puppet_1 --tail 10) | grep -q 'Puppet Server has successfully started' && break; if [ $i -gt 90 ]; then exit 1; fi; sleep 1; i=$[$i+1]; done; 31 | docker exec spec_puppet_1 puppetserver ca generate --certname aceserver --subject-alt-names 'puppet,localhost,aceserver,ace_aceserver_1,spec_puppetserver_1,ace_server,puppet_server,spec_aceserver_1,puppetdb,spec_puppetdb_1,0.0.0.0' 32 | mkdir -p /opt/puppetlabs/puppet/bin/ 33 | ln -svf $(bundle exec which ruby) /opt/puppetlabs/puppet/bin/ruby 34 | sudo chmod a+rx -R spec/volumes 35 | - name: Run tests 36 | run: bundle exec rake 37 | -------------------------------------------------------------------------------- /config/transport_tasks_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | #################################################### 4 | ## DO NOT EDIT THIS FILE ## 5 | ## Use /etc/puppetlabs/ace/conf.d/ace-server.conf ## 6 | ## to configure the sinatra server ## 7 | #################################################### 8 | 9 | require 'ace/transport_app' 10 | require 'ace/config' 11 | require 'bolt_server/acl' 12 | require 'bolt/logger' 13 | 14 | Bolt::Logger.initialize_logging 15 | 16 | config_path = ENV['ACE_CONF'] || '/etc/puppetlabs/ace-server/conf.d/ace-server.conf' 17 | 18 | config = ACE::Config.new 19 | config.load_file_config(config_path) 20 | config.load_env_config 21 | config.make_compatible 22 | config.validate 23 | 24 | Logging.logger[:root].add_appenders Logging.appenders.stderr( 25 | 'console', 26 | layout: Bolt::Logger.default_layout, 27 | level: config['loglevel'] 28 | ) 29 | 30 | if config['logfile'] 31 | stdout_redirect config['logfile'], config['logfile'], true 32 | end 33 | 34 | bind_addr = +"ssl://#{config['host']}:#{config['port']}?" 35 | bind_addr << "cert=#{config['ssl-cert']}" 36 | bind_addr << "&key=#{config['ssl-key']}" 37 | bind_addr << "&ca=#{config['ssl-ca-cert']}" 38 | bind_addr << "&verify_mode=force_peer" 39 | bind_addr << "&ssl_cipher_filter=#{config['ssl-cipher-suites'].join(':')}" 40 | bind bind_addr 41 | 42 | threads 0, config['concurrency'] 43 | 44 | impl = ACE::TransportApp.new(config) 45 | unless config['allowlist'].nil? 46 | impl = BoltServer::ACL.new(impl, config['allowlist']) 47 | end 48 | 49 | app impl 50 | -------------------------------------------------------------------------------- /lib/puppet/indirector/catalog/certless.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'puppet/resource/catalog' 4 | require 'puppet/indirector/rest' 5 | 6 | module Puppet 7 | class Resource 8 | class Catalog 9 | class Certless < Puppet::Indirector::REST 10 | desc "Find certless catalogs over HTTP via REST." 11 | 12 | def find(request) 13 | certname = request.key 14 | payload = { 15 | persistence: { facts: true, catalog: true }, 16 | environment: request.environment.name.to_s, 17 | facts: request.options[:transport_facts], 18 | trusted_facts: request.options[:trusted_facts], 19 | transaction_uuid: request.options[:transaction_uuid], 20 | job_id: request.options[:job_id], 21 | options: { 22 | prefer_requested_environment: false, 23 | capture_logs: false 24 | } 25 | } 26 | session = Puppet.lookup(:http_session) 27 | api = session.route_to(:puppet) 28 | _, catalog, = api.post_catalog4(certname, **payload) 29 | catalog 30 | rescue Puppet::HTTP::ResponseError => e 31 | if e.response.code == 404 32 | return nil unless request.options[:fail_on_404] 33 | 34 | _, body = parse_response(e.response) 35 | msg = "Find resulted in 404 with the message: #{body}" 36 | raise Puppet::Error, msg 37 | else 38 | raise convert_to_http_error(e.response) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agentless::Catalog::Executor 2 | 3 | ## App Overview 4 | 5 | The Agentless Catalog Executor (ACE) provides agentless executions services for tasks and catalogs to Puppet Enterprise (PE). See [developer-docs/api](developer-docs/api.md) for an API spec. 6 | 7 | See below for development info. 8 | 9 | ## Code Overview 10 | 11 | API entrypoints are in `lib/ace/transport_app.rb`. 12 | 13 | Fork isolation is implemented in `lib/ace/fork_utils.rb` 14 | 15 | Catalog compilations use the certless [v4 catalog](https://github.com/puppetlabs/puppetserver/blob/master/documentation/puppet-api/v4/catalog.markdown) puppetserver endpoint and expose it through the indirector in `lib/puppet/indirector/catalog/certless.rb`. 16 | 17 | ## Installation 18 | 19 | ACE is built-in to PE as pe-ace-server. 20 | 21 | ## Development 22 | 23 | As ACE is dependent on Puppet Server, there is a docker-compose file in the `spec/` directory which we advise you run before the ACE service to ensure that the certs and keys are valid. For more information, see the [docker documentation](developer-docs/docker.md). 24 | 25 | To release a new version, update the version number in `version.rb`, generate a new changelog with `bundle exec rake changelog`, commit the results and run `bundle exec rake release`, which creates a git tag for the version, pushes git commits and tags, and pushes the `.gem` file to [rubygems.org](https://rubygems.org). Released gems are eventually consumed by [ace-vanagon](https://github.com/puppetlabs/ace-vanagon) and promoted into PE. 26 | 27 | ## Contributing 28 | 29 | Bug reports and pull requests are welcome on GitHub at https://github.com/puppetlabs/ace. See the `.travis.yml` file for which checks to run on your code before submitting. Always include tests with your changes. 30 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7JMHJIOZzIJd+ 3 | xoCcwb3F7BGO5aEIE1Mwdvn7GWmDV3U3O2I0oD68otoyim3E9EwbKF8gUcfnRr/A 4 | LKw49DGKO5pQiGDXZKov9Cun0oOv8CmrsHATf9PQsEQ/w7l4FaL1uUXidrWvdpQS 5 | qn41trC++u69SwXlv7EB5rDSBZ+B59Xg5i/IC/GE55aMB2HnyIihh0OpKsR77y64 6 | qR/4p+ZIfiSF/ofTUHOJhkqlG7B1/6cF3wRUdsmfsCgfFisLjgftjbZKs5HBUMVb 7 | ThiHqwkPsXYlu8gB8RYJwGHC5WgQ9nl9uZ2FV6dON2SP2k1WC5aJkWZvQE/7M8NQ 8 | lAhGOx9pAgMBAAECggEBAI2Pgl7H2kpf7vjg4syw6QJcTfPP032uUJEqjnHYiS3m 9 | 7C25Z9HzHUHH1lHA0MPZH/CzGzHxasuRgt61bBkw7oBoEZS1dLu509quUo+B+EcP 10 | 3hWXQ1Acs3b6vsWVIiiqBTjmyuxBa7GsetmbyhiLdysf2ZOqum1OEXEktcBIrJ2w 11 | GuSLWxGGKkXMrDcvXiRvop0WH5ACXO6NXLPt5YFP7ze95C0XH+rqBFGFATKrAbr1 12 | AWYpScXTxk5pnd6+KAZArhsAY10pg988765KeemcIqzdzlL2DfcfCq87AaSj6RDp 13 | o+4Mv2e9PTknzDfLos3U6h/3FIObcvUSWisfQZs3QQECgYEA6y5iAkrofvamdum9 14 | qSlyC2bgnbyZ0GqJeM9ClnfT6zvQ61dTbz8yEl6Zf695kR+G+mGOSHZAY4uLmawH 15 | 466MOjb21IiOfsvgTnoEMHFH9hHdC4CCKRZRWKiPT4yVDeJTx8hRu0MiFvXr8sXI 16 | LpVCwAJ5mGg3RajOnPrV9YcGY9kCgYEAy7XCDgEqSSFZ49iNrEn5Ni3ZzKH6UzcN 17 | OWQINEKS5BJHblsnlZ6SKQw5LMm3tLOQaqZ4ahMqnSLhmT2GkKDD7+FAdJAUmxva 18 | 85rZ2MfqJw27fS4hipwIAHquyhAHwiESO28cJsWj1LQwHAnBAuQQ//rP24XKfba+ 19 | 14udw3TZrhECgYEA4NxPP0vp8gLYdJfWDFihPv+VQZvjIR/L4yOf0gguKhreDTZI 20 | gvRUZrXmY+wd+sC/KMR/6w6NT+BLkJmoTWxFjR+ibuFGBTvfcok9WiflmwPHakjr 21 | qmc5TeCUbYXHuies3TbN1pNAk918RHoQhWGXGckEA9GZD1RwgC1gx0nbtTkCgYAz 22 | ehW0lkTKQBxIAGQkonjQYRvFozTvrFyyIP4VvrEB40CbuXsySuTibI1SLRM4HZwj 23 | 5zdMjtVY6gSaRbrod0esKX6xNeuPGqXkoz/jkPkxrz2ur2aYcT9wZ5AdzZk4TEUg 24 | Mb6qaY0x5eq2WxykD1/gX9AAyDlYNULakxRl3PRZEQKBgHOkh0r7paNtlD/2JbwI 25 | ttKLq9rl12w6qKCQnJOjGOUdXNPdErjSeQ9IinC/76RYTq1CRHQ9k7Uh4Gi0MK0f 26 | rzWckwaVPF6jlWQAUyIqkjKF7Wz8VXTFiCRO7HVcv5DBwgFNV4NeVzwVNE3Q/BJu 27 | i9Kwklw0mPzy8KNQLVQoKJAt 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /agentless-catalog-executor.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "ace/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "agentless-catalog-executor" 9 | spec.version = ACE::VERSION 10 | spec.authors = ["David Schmitt"] 11 | spec.email = ["david.schmitt@puppet.com"] 12 | 13 | spec.summary = 'ACE lets you run remote tasks and catalogs using puppet and bolt.' 14 | spec.homepage = "https://github.com/puppetlabs/agentless-catalog-executor" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = "exe" 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ["lib"] 22 | 23 | spec.required_ruby_version = ">=2.5" 24 | 25 | spec.add_dependency "bolt", ">= 3.20" 26 | 27 | # server-side dependencies cargo culted from https://github.com/puppetlabs/bolt/blob/4418da408643aa7eb5ed64f4c9704b941ea878dc/Gemfile#L10-L16 28 | spec.add_dependency "hocon", ">= 1.2.5" 29 | spec.add_dependency "json-schema", ">= 2.8.0" 30 | spec.add_dependency "puma", ">= 3.12.0" 31 | spec.add_dependency "puppet", ">= 6.23.0", "< 9.0.0" 32 | spec.add_dependency "rack", ">= 2.0.5" 33 | spec.add_dependency "rails-auth", ">= 2.1.4" 34 | spec.add_dependency "sinatra", ">= 2.0.4" 35 | 36 | spec.add_development_dependency "bundler", ">= 1.16", "< 3.0.0" 37 | spec.add_development_dependency "faraday" 38 | spec.add_development_dependency "rack-test", "~> 1.0" 39 | spec.add_development_dependency "rake", "~> 13.0" 40 | spec.add_development_dependency "rspec", "~> 3.0" 41 | spec.add_development_dependency "rubocop", "~> 0.50" 42 | end 43 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/ca_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,4820AF71A3FE4F7E2C8FB5C14157A41D 4 | 5 | qeYNPPriM7/LrpsSAK6VqZeuA6EtGqEK+z8qt6tQgAxLH6oIGA+vGnTE21xe54dX 6 | cBtjG0snzHZvZxSljlMUJtE2aP5D5jcEOWh+Bcz7SvADNHwdAKUdUxco0fIsT9fY 7 | qc0MXMMLtYoeRglnKZmxRpwkcXdFQsWJH/t5tHLwV7bFadPwZD1OIIuZkSJU9nIv 8 | KWwLvDbWD9E7N53r9gnq713aVfLofYJeMcolCRwQsmbA2WvkHs1NlLtTA+BfPbR1 9 | 6XFzIjkcq47L959ikWjc+TIyI9lJeHsVgCm+x+0/r4nu2JQTC2IAuHQKZV/zUqsO 10 | cRn6w5Fsvxfl+ZHGM4/jlM97RjiYEEKJ0CUp7P1GoTFzwQcSvLn/knhLKOYv66mU 11 | M2KAFEE1KxDwF18gI2j0Aim+bEyO5oW7fRbOBGtL+omm2STKpOLBYwJ35KlmL2DK 12 | wUTAYlFX8PRXdobgocwsmY+5V2LzrRH7UTpVj1Xk4uFqeVzTwvXIm18GLurqadIa 13 | 10yN9PmlhdG655guRxaS53ygyFW5+rEKxvv5fGWv68dseCOQr3OL/UxeTnDBYa1s 14 | 3DFciiiuKXia8828qVZTnk9GJq7K19sdZqX0f8nirzNutgkcGgL+YF/cc7MMUrS7 15 | k0O99q4skUMkhGfUr666vHRdMQgSJXOYB10ejOlBaEzh7Kwa0yrq8jI04HuuUlKT 16 | aVIDCfvsxv8+7M2Di1b03ZQelbISaN6Z5NHFXbudUc1I9wDDu8Oy6nZ1HMvmb85R 17 | N9HULFHOD8ubpgM8SSBT1GyWJT7ZiKOy+dhMtN7Sj3WCWg3lxjXLHVCW4lEd9vAG 18 | tenkbtj01CGaDDngc3+BuTjILzrUYt25DnK/9ob4GisSwHJBSXndyIV90dVhOLC+ 19 | c4BVKiNCZYEtjt3SjG5KUmc6UtnM85Dz3XP+OxIK4sfxYaj54N+sZyNcHJfdC5p+ 20 | J4u88m2bNMrb8Gr+sazNBlmG08RFJZLKGtFKUjHV4OmnKO+jKyvlAnAU164BIfaR 21 | hkE5yr2SgbT6gK5xMQ6YW+OKtSHh8zJIDQZGi7zCUZXZ1LkwN9bQ7hPArVyQTXol 22 | 2GT5zvnJDagZOYJrs/DYhA+i0Gl02m4ue+/8ER/ORvYPQ3gMGH0Gtsmb7vbXvEIv 23 | N+rOnCDA8TNUZOIth/AzOqi/YEf83YSYkVcDp4D4/tpOFZUYzNyqk84mBK0R7VTM 24 | UAnK9bVUzLhfhL/++emB/gJtZuqNUq2gmQUJBgAzhj1SvqQnMxpthEK7kthqZYMD 25 | cC4LHMwyT7AaPmbfHeGXHdJbtNC0scHNEcOyPyu7IUicC2Ufm6Hz1UtsPuYdmHD3 26 | t54fqAJowCwyw6U2DBeo9ytmPAjqbLarHMpSXub8USH5GCpx/PG0VL4P1Q0b8rPX 27 | OLzDjYexjkS1O66ArpwkpH8aTkHuVcc7xAYW81z7Ni908FmIe3/K4/gREzz8VbEC 28 | O71FO4AjUTDbIu5MevY/HL2kFPoeevNrVdjfZkphUzg219MeD38TRBBbKy8m4SwS 29 | RbfC34ksc2hG3GR3PHd11WeR1AbF/1H6Z45rIoQlBIiIHco9HdbxDhcZ9mBma8UE 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /acceptance/lib/acceptance/ace_command_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Acceptance 4 | module AceCommandHelper 5 | # A helper to build a bolt command used in acceptance testing 6 | # @param [Beaker::Host] host the host to execute the command on 7 | # @param [String] command the command to execute on the bolt SUT 8 | # @param [Hash] flags the command flags to append to the command 9 | # @option flags [String] '--nodes' the nodes to run on 10 | # @option flags [String] '--user' the user to run the command as 11 | # @option flags [String] '--password' the password for the user 12 | # @option flags [nil] '--no-host-key-check' specify nil to use 13 | # @option flags [nil] '--no-ssl' specify nil to use 14 | # @param [Hash] opts the options hash for this method 15 | def bolt_command_on(host, command, flags = {}, opts = {}) 16 | bolt_command = command.dup 17 | flags.each { |k, v| bolt_command << " #{k} #{v}" } 18 | 19 | case host['platform'] 20 | when /windows/ 21 | execute_powershell_script_on(host, bolt_command, opts) 22 | when /osx/ 23 | # Ensure Bolt runs with UTF-8 under macOS. Otherwise we get issues with 24 | # UTF-8 content in task results. 25 | env = 'source /etc/profile ~/.bash_profile ~/.bash_login ~/.profile && env LANG=en_US.UTF-8' 26 | on(host, env + ' ' + bolt_command) 27 | else 28 | on(host, bolt_command, opts) 29 | end 30 | end 31 | 32 | def default_boltdir 33 | @default_boltdir ||= begin 34 | query = bolt['platform'] =~ /windows/ ? 'cygpath -m $(printenv HOME)' : 'printenv HOME' 35 | home = on(bolt, query).stdout.chomp 36 | File.join(home, '.puppetlabs/bolt') 37 | end 38 | end 39 | 40 | def modulepath(extra) 41 | case bolt['platform'] 42 | when /windows/ 43 | "\"#{default_boltdir}/modules;#{extra}\"" 44 | else 45 | "#{default_boltdir}/modules:#{extra}" 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/ace/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hocon' 4 | require 'bolt_server/base_config' 5 | 6 | module ACE 7 | class Config < BoltServer::BaseConfig 8 | attr_reader :data 9 | def config_keys 10 | super + %w[concurrency cache-dir puppet-server-conn-timeout puppet-server-uri ssl-ca-crls] 11 | end 12 | 13 | def env_keys 14 | super + %w[concurrency puppet-server-conn-timeout puppet-server-uri ssl-ca-crls] 15 | end 16 | 17 | def ssl_keys 18 | super + %w[ssl-ca-crls] 19 | end 20 | 21 | def int_keys 22 | %w[concurrency puppet-server-conn-timeout] 23 | end 24 | 25 | def defaults 26 | super.merge( 27 | 'port' => 44633, 28 | 'concurrency' => 10, 29 | 'cache-dir' => "/opt/puppetlabs/server/data/ace-server/cache", 30 | 'puppet-server-conn-timeout' => 120, 31 | 'file-server-conn-timeout' => 120 32 | ) 33 | end 34 | 35 | def required_keys 36 | super + %w[puppet-server-uri cache-dir] 37 | end 38 | 39 | def service_name 40 | 'ace-server' 41 | end 42 | 43 | def load_env_config 44 | env_keys.each do |key| 45 | transformed_key = "ACE_#{key.tr('-', '_').upcase}" 46 | next unless ENV.key?(transformed_key) 47 | @data[key] = if int_keys.include?(key) 48 | ENV[transformed_key].to_i 49 | else 50 | ENV[transformed_key] 51 | end 52 | end 53 | end 54 | 55 | def validate 56 | super 57 | 58 | unless natural?(@data['concurrency']) 59 | raise Bolt::ValidationError, "Configured 'concurrency' must be a positive integer" 60 | end 61 | 62 | unless natural?(@data['puppet-server-conn-timeout']) 63 | raise Bolt::ValidationError, "Configured 'puppet-server-conn-timeout' must be a positive integer" 64 | end 65 | end 66 | 67 | def make_compatible 68 | # This function sets values used by Bolt that behave the same in ACE, but have a different meaning 69 | @data['file-server-uri'] = @data['puppet-server-uri'] 70 | @data['file-server-conn-timeout'] = @data['puppet-server-conn-timeout'] 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/ace/schemas/task.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "file:task", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "title": "Task", 5 | "description": "Task schema for bolt-server", 6 | "type": "object", 7 | "properties": { 8 | "name": { 9 | "type": "string", 10 | "description": "Task name" 11 | }, 12 | "metadata": { 13 | "type": "object", 14 | "description": "The metadata object is optional, and contains metadata about the task being run", 15 | "properties": { 16 | "description": { 17 | "type": "string", 18 | "description": "The task description from it's metadata" 19 | }, 20 | "parameters": { 21 | "type": "object", 22 | "description": "Object whose keys are parameter names, and values are objects", 23 | "properties": { 24 | "description": { 25 | "type": "string", 26 | "description": "Parameter description" 27 | }, 28 | "type": { 29 | "type": "string", 30 | "description": "The type the parameter should accept" 31 | }, 32 | "sensitive": { 33 | "description": "Whether the task runner should treat the parameter value as sensitive", 34 | "type": "boolean" 35 | } 36 | } 37 | }, 38 | "input_method": { 39 | "type": "string", 40 | "enum": ["stdin", "environment", "powershell"], 41 | "description": "What input method should be used to pass params to the task" 42 | } 43 | } 44 | }, 45 | "files": { 46 | "type": "array", 47 | "description": "Description of task files", 48 | "items": { 49 | "type": "object", 50 | "properties": { 51 | "uri": { 52 | "type": "object", 53 | "description": "Where is the file" 54 | }, 55 | "sha256": { 56 | "type": "string", 57 | "description": "checksum of file" 58 | }, 59 | "filename": { 60 | "type": "string", 61 | "description": "Name of file" 62 | }, 63 | "size": { 64 | "type": "number", 65 | "description": "Size of file" 66 | } 67 | } 68 | }, 69 | "required": ["filename", "uri", "sha256"] 70 | } 71 | }, 72 | "required": ["name", "files"], 73 | "additionalProperties": false 74 | } -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | AllCops: 3 | TargetRubyVersion: 2.5 4 | Exclude: 5 | - 'vendor/**/*' 6 | - 'vendored/**/*' 7 | - 'acceptance/vendor/**/*' 8 | - 'modules/**/*' 9 | - 'spec/volumes/**/*' 10 | - 'tmp/**/*' 11 | 12 | # Checks for if and unless statements that would fit on one line if written as a 13 | # modifier if/unless. 14 | Style/IfUnlessModifier: 15 | Enabled: false 16 | 17 | Style/WordArray: 18 | Enabled: false 19 | 20 | Style/AccessModifierDeclarations: 21 | Enabled: false 22 | 23 | Style/StringLiterals: 24 | Enabled: false 25 | 26 | Style/Documentation: 27 | Enabled: false 28 | 29 | Style/BlockDelimiters: 30 | Enabled: false 31 | 32 | Style/NumericLiterals: 33 | Enabled: false 34 | 35 | Style/NumericPredicate: 36 | Enabled: false 37 | 38 | Style/RedundantBegin: 39 | Enabled: false 40 | 41 | Layout/EmptyLineAfterGuardClause: 42 | Enabled: false 43 | 44 | Layout/HeredocIndentation: 45 | Enabled: false 46 | 47 | Layout/ClosingHeredocIndentation: 48 | Enabled: false 49 | 50 | Layout/LineLength: 51 | Max: 120 52 | 53 | Style/GuardClause: 54 | Enabled: false 55 | 56 | Style/MultilineBlockChain: 57 | Enabled: false 58 | 59 | Style/DoubleNegation: 60 | Enabled: false 61 | 62 | Style/SafeNavigation: 63 | Enabled: false 64 | 65 | # Disable nearly all Metrics checks. These seem better off left to judgement. 66 | 67 | Metrics/AbcSize: 68 | Enabled: false 69 | 70 | Metrics/BlockLength: 71 | Enabled: false 72 | 73 | Metrics/BlockNesting: 74 | Enabled: false 75 | 76 | Metrics/ClassLength: 77 | Enabled: false 78 | 79 | Metrics/CyclomaticComplexity: 80 | Enabled: false 81 | 82 | Metrics/MethodLength: 83 | Enabled: false 84 | 85 | Metrics/ModuleLength: 86 | Enabled: false 87 | 88 | Metrics/ParameterLists: 89 | Enabled: false 90 | 91 | Metrics/PerceivedComplexity: 92 | Enabled: false 93 | 94 | # Disable some of the stricter rspec tests, not useful for the webmock/sinatra tests 95 | RSpec/MultipleExpectations: 96 | Enabled: false 97 | 98 | RSpec/ExampleLength: 99 | Enabled: false 100 | 101 | # see: https://github.com/rubocop-hq/rubocop/issues/4222 102 | Lint/AmbiguousBlockAssociation: 103 | Exclude: 104 | - "spec/**/*" 105 | 106 | # complex code is complex :-( 107 | RSpec/NestedGroups: 108 | Enabled: true 109 | Max: 5 110 | 111 | # TODO: do we refactor? 112 | RSpec/MultipleMemoizedHelpers: 113 | Enabled: false -------------------------------------------------------------------------------- /lib/ace/fork_util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # English module required for $CHILD_STATUS rather than $? 4 | require 'English' 5 | require 'json' 6 | require 'ace/error' 7 | 8 | module ACE 9 | class ForkUtil 10 | # Forks and calls a function 11 | # It is expected that the function returns a JSON response 12 | # Throws an exception if JSON.generate fails to generate 13 | def self.isolate(timeout = nil) 14 | reader, writer = IO.pipe 15 | pid = fork { 16 | # :nocov: 17 | success = true 18 | begin 19 | response = yield 20 | writer.puts JSON.generate(response) 21 | rescue ACE::Error => e 22 | writer.puts({ 23 | msg: e.message, 24 | kind: e.kind, 25 | details: { 26 | class: e.class, 27 | backtrace: e.backtrace 28 | } 29 | }.to_json) 30 | success = false 31 | rescue Exception => e # rubocop:disable Lint/RescueException 32 | writer.puts({ 33 | msg: e.message, 34 | kind: e.class, 35 | details: { 36 | class: e.class, 37 | backtrace: e.backtrace 38 | } 39 | }.to_json) 40 | success = false 41 | ensure 42 | writer.flush 43 | Process.exit! success 44 | end 45 | # :nocov: 46 | } 47 | unless pid 48 | warn "Could not fork" 49 | exit 1 50 | end 51 | output = nil 52 | if timeout && timeout > 0 53 | begin 54 | Timeout.timeout(timeout) do 55 | writer.close 56 | output = reader.readlines('')[0] 57 | Process.wait(pid) 58 | end 59 | rescue Timeout::Error 60 | Process.kill(9, pid) 61 | # collect status so it doesn't stick around as zombie process 62 | Process.wait(pid) 63 | raise ACE::Error.new("Operation timed out after #{timeout} seconds", 'puppetlabs/ace/fork_util', 'no details') 64 | end 65 | else 66 | writer.close 67 | output = reader.readlines('')[0] 68 | Process.wait(pid) 69 | end 70 | if $CHILD_STATUS != 0 71 | error = JSON.parse(output) 72 | raise ACE::Error.new(error['msg'], error['kind'], error['details']) 73 | elsif output.nil? 74 | raise ACE::Error.new('spawned process returned no result', 'puppetlabs/ace/fork_util', 'no details') 75 | else 76 | JSON.parse(output) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/ace/schemas/ace-execute_catalog.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "ACE execute_catalog request", 4 | "description": "POST /execute_catalog request schema for ACE", 5 | "type": "object", 6 | "properties": { 7 | "target": { 8 | "type": "object", 9 | "description": "Contains the Transport schema to connect to the remote target", 10 | "properties": { 11 | "remote-transport": { 12 | "type": "string", 13 | "description": "The name of the transport being used" 14 | } 15 | }, 16 | "additionalProperties": true, 17 | "required": ["remote-transport"] 18 | }, 19 | "timeout": { 20 | "type": "integer", 21 | "description": "Number of seconds to wait before abandoning the task execution on the tartet." 22 | }, 23 | "compiler": { 24 | "type": "object", 25 | "description": "Contains additional information to compile the catalog", 26 | "properties": { 27 | "certname": { 28 | "type": "string", 29 | "description": "The certname of the target" 30 | }, 31 | "environment": { 32 | "type": "string", 33 | "description": "The name of the environment for which to compile the catalog." 34 | }, 35 | "enforce_environment": { 36 | "type": "boolean", 37 | "description": "Whether to force agents to run in the same environment in which their assigned applications are defined. (This key is required to be false if `environment` is an empty string)." 38 | }, 39 | "transaction_uuid": { 40 | "type": "string", 41 | "description": "The id for tracking the catalog compilation and report submission." 42 | }, 43 | "job_id": { 44 | "type": "string", 45 | "description": "The id of the orchestrator job that triggered this run." 46 | }, 47 | "noop": { 48 | "type": "boolean", 49 | "description": "The operation should not be applied", 50 | "default": false 51 | }, 52 | "debug": { 53 | "type": "boolean", 54 | "description": "Show up to debug level messages", 55 | "default": false 56 | }, 57 | "trace": { 58 | "type": "boolean", 59 | "description": "Allows for a backtrace to be returned in the event of an exception", 60 | "default": false 61 | }, 62 | "evaltrace": { 63 | "type": "boolean", 64 | "description": "Reports on each step of the Puppet process", 65 | "default": false 66 | } 67 | }, 68 | "additionalProperties": true, 69 | "required": [ 70 | "certname", 71 | "environment", 72 | "enforce_environment" 73 | ] 74 | } 75 | }, 76 | "required": ["target", "compiler"] 77 | } 78 | -------------------------------------------------------------------------------- /spec/unit/ace/fork_util_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/fork_util' 5 | 6 | # Integration level tests, to prove out functionality 7 | RSpec.describe ACE::ForkUtil do 8 | describe "#isolate" do 9 | context "when everything works out" do 10 | it 'returns the result' do 11 | return_value = described_class.isolate do 12 | "test string" 13 | end 14 | expect(return_value).to eq "test string" 15 | end 16 | 17 | it 'isolates block from global scope' do 18 | # rubocop:disable Style/GlobalVars 19 | $global = 'string_outside' 20 | 21 | described_class.isolate do 22 | $global = 'string_inside' 23 | end 24 | 25 | expect($global).to eq 'string_outside' 26 | # rubocop:enable Style/GlobalVars 27 | end 28 | end 29 | 30 | context "when errors occur" do 31 | it 'exception thrown when the block returns invalid JSON' do 32 | expect { 33 | described_class.isolate do 34 | Float::NAN 35 | end 36 | }.to raise_error(RuntimeError, /NaN not allowed in JSON/) 37 | end 38 | 39 | it 'ACE exception thrown when the block raises an ACE error' do 40 | expect { 41 | described_class.isolate do 42 | raise ACE::Error.new('my message', 'demo/demo') 43 | end 44 | }.to raise_error(ACE::Error, /my message/) 45 | end 46 | 47 | it 'invalid JSON is correctly returned as a string' do 48 | return_value = described_class.isolate do 49 | '1. 2. 3. [. "test" : 123. ]' 50 | end 51 | expect(return_value).to eq '1. 2. 3. [. "test" : 123. ]' 52 | end 53 | 54 | it "an empty response is correctly returned as empty string" do 55 | return_value = described_class.isolate do 56 | '' 57 | end 58 | expect(return_value).to eq '' 59 | end 60 | 61 | it "a `nil` response is correctly returned as `nil`" do 62 | return_value = described_class.isolate do 63 | nil 64 | end 65 | expect(return_value).to be_nil 66 | end 67 | end 68 | 69 | describe "fork failures" do 70 | before do 71 | allow(described_class).to receive(:fork).and_return(nil) 72 | end 73 | 74 | it "exits the process" do 75 | expect { described_class.isolate {} }.to raise_error SystemExit 76 | end 77 | end 78 | 79 | describe 'premature fork exit' do 80 | let(:reader) { instance_double(IO, 'reader') } 81 | let(:writer) { instance_double(IO, 'writer') } 82 | 83 | before do 84 | allow(described_class).to receive(:fork).and_return(true) 85 | allow(Process).to receive(:wait).with(true) 86 | allow(IO).to receive(:pipe).and_return([reader, writer]) 87 | allow(writer).to receive(:close) 88 | # the forked process can go away without returning any output 89 | allow(reader).to receive(:readlines).with('').and_return([]) 90 | end 91 | 92 | it 'raises a fork_util error' do 93 | expect { described_class.isolate {} }.to raise_error ACE::Error, /spawned process returned no result/ 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /.dependency_decisions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - - :permit 3 | - MIT 4 | - :who: DavidS 5 | :why: standard license 6 | :versions: [] 7 | :when: 2017-07-28 11:11:09.971500380 Z 8 | - - :permit 9 | - Apache 2.0 10 | - :who: DavidS 11 | :why: standard license 12 | :versions: [] 13 | :when: 2017-07-28 11:12:21.086779416 Z 14 | - - :permit 15 | - ruby 16 | - :who: DavidS 17 | :why: standard license 18 | :versions: [] 19 | :when: 2017-07-28 11:12:28.578927478 Z 20 | - - :permit 21 | - Simplified BSD 22 | - :who: DavidS 23 | :why: standard license 24 | :versions: [] 25 | :when: 2017-07-28 11:12:36.924605442 Z 26 | - - :permit 27 | - New BSD 28 | - :who: DavidS 29 | :why: standard license 30 | :versions: [] 31 | :when: 2017-07-28 11:14:00.252514982 Z 32 | - - :permit 33 | - Apache License, v2 34 | - :who: DavidS 35 | :why: standard license 36 | :versions: [] 37 | :when: 2017-07-28 11:14:07.999759997 Z 38 | - - :permit 39 | - Ruby or LGPLv3+ 40 | - :who: DavidS 41 | :why: standard license 42 | :versions: [] 43 | :when: 2017-07-28 11:18:47.915684798 Z 44 | - - :license 45 | - bundler 46 | - MIT 47 | - :who: DavidS 48 | :why: See https://rubygems.org/gems/bundler 49 | :versions: [] 50 | :when: 2017-07-28 11:22:12.975947786 Z 51 | - - :license 52 | - minitar 53 | - ruby 54 | - :who: DavidS 55 | :why: https://rubygems.org/gems/minitar 56 | :versions: [] 57 | :when: 2017-07-28 11:22:50.609762862 Z 58 | - - :license 59 | - colored 60 | - MIT 61 | - :who: DavidS 62 | :why: https://github.com/defunkt/colored/blob/829bde0f8832406be1cacc5c99c49d976e05ccfc/LICENSE 63 | :versions: [] 64 | :when: 2017-07-28 11:23:25.554994001 Z 65 | - - :permit 66 | - ISC 67 | - :who: scotje 68 | :why: MIT equivalent 69 | :versions: [] 70 | :when: 2017-08-11 01:30:32.594531000 Z 71 | - - :license 72 | - facter 73 | - Apache 2.0 74 | - :who: DavidS 75 | :why: https://github.com/puppetlabs/facter/blob/d6e1d4bcb087683239ff5ee6bd72978d262b9c39/LICENSE 76 | :versions: [] 77 | :when: 2017-08-31 13:26:57.197893553 Z 78 | - - :license 79 | - codecov 80 | - MIT 81 | - :who: DavidS 82 | :why: https://github.com/codecov/codecov-ruby/blob/d5f85a0ece83845a577e26f8ce735ae10de767c0/LICENSE.txt 83 | :versions: [] 84 | :when: 2017-10-11 12:15:00.000000000 Z 85 | - - :license 86 | - blankslate 87 | - MIT 88 | - :who: DavidS 89 | :why: https://github.com/masover/blankslate/blob/f0a73b80c34bc3ad94dc3195d12a227a8fe30019/MIT-LICENSE 90 | :versions: [] 91 | :when: 2017-11-17 14:05:09.534340925 Z 92 | - - :license 93 | - puppetlabs_spec_helper 94 | - Apache 2.0 95 | - :who: DavidS 96 | :why: 97 | :versions: [] 98 | :when: 2018-03-09 18:04:29.175843919 Z 99 | - - :license 100 | - log4r 101 | - BSD 3-Clause 102 | - :who: da-ar 103 | :why: https://github.com/colbygk/log4r/blob/4e041411c289963d997ee1ebd06fd9875eb40de5/LICENSE 104 | :versions: [] 105 | :when: 2018-11-28 11:46:02.452422000 Z 106 | - - :permit 107 | - BSD 3-Clause 108 | - :who: da-ar 109 | :why: standard license 110 | :versions: [] 111 | :when: 2018-11-28 11:49:22.614641000 Z 112 | - - :license 113 | - windows_error 114 | - BSD 3-Clause 115 | - :who: da-ar 116 | :why: https://github.com/rapid7/windows_error/blob/master/LICENSE.txt 117 | :versions: [0.1.2] 118 | :when: 2019-02-28 15:56:41.045616000 Z 119 | -------------------------------------------------------------------------------- /lib/ace/plugin_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'ace/puppet_util' 5 | require 'puppet/configurer' 6 | require 'concurrent' 7 | require 'ace/fork_util' 8 | 9 | module ACE 10 | class PluginCache 11 | attr_reader :cache_dir_mutex, :cache_dir 12 | 13 | PURGE_TIMEOUT = 60 * 60 14 | PURGE_INTERVAL = 24 * PURGE_TIMEOUT 15 | PURGE_TTL = 7 * PURGE_INTERVAL 16 | 17 | def initialize(environments_cache_dir, 18 | purge_interval: PURGE_INTERVAL, 19 | purge_timeout: PURGE_TIMEOUT, 20 | purge_ttl: PURGE_TTL, 21 | cache_dir_mutex: Concurrent::ReadWriteLock.new, 22 | do_purge: true) 23 | @cache_dir = environments_cache_dir 24 | @cache_dir_mutex = cache_dir_mutex 25 | 26 | if do_purge 27 | @purge = Concurrent::TimerTask.new(execution_interval: purge_interval, 28 | timeout_interval: purge_timeout, 29 | run_now: true) { expire(purge_ttl) } 30 | @purge.execute 31 | end 32 | end 33 | 34 | def setup 35 | FileUtils.mkdir_p(cache_dir) 36 | self 37 | end 38 | 39 | def with_synced_libdir(environment, enforce_environment, certname, timeout, &block) 40 | ForkUtil.isolate(timeout) do 41 | ACE::PuppetUtil.isolated_puppet_settings( 42 | certname, 43 | environment, 44 | enforce_environment, 45 | environment_dir(environment) 46 | ) 47 | with_synced_libdir_core(environment, &block) 48 | end 49 | end 50 | 51 | def with_synced_libdir_core(environment) 52 | libdir = sync_core(environment) 53 | Puppet.settings[:libdir] = libdir 54 | $LOAD_PATH << libdir 55 | yield 56 | ensure 57 | FileUtils.remove_dir(libdir) 58 | end 59 | 60 | # the Puppet[:libdir] will point to a tmp location 61 | # where the contents from the pluginsync dest is copied 62 | # too. 63 | def libdir(plugin_dest) 64 | tmpdir = Dir.mktmpdir(['plugins', plugin_dest]) 65 | cache_dir_mutex.with_write_lock do 66 | FileUtils.cp_r(File.join(plugin_dest, '.'), tmpdir) 67 | FileUtils.touch(tmpdir) 68 | end 69 | tmpdir 70 | end 71 | 72 | def environment_dir(environment) 73 | environment_dir = File.join(cache_dir, environment) 74 | cache_dir_mutex.with_write_lock do 75 | FileUtils.mkdir_p(File.join(environment_dir, 'code', 'environments', environment)) 76 | FileUtils.touch(environment_dir) 77 | end 78 | environment_dir 79 | end 80 | 81 | # @returns the tmp libdir directory which will be where 82 | # Puppet[:libdir] is referenced too 83 | def sync_core(environment) 84 | env = Puppet::Node::Environment.remote(environment) 85 | environments_dir = environment_dir(environment) 86 | Puppet::Configurer::PluginHandler.new.download_plugins(env) 87 | libdir(File.join(environments_dir, 'plugins')) 88 | end 89 | 90 | # the cache_dir will be the `cache-dir` from 91 | # the ace config, with the appended environments, i.e. 92 | # /opt/puppetlabs/server/data/ace-server/cache/environments 93 | # then the directories within this path, which will be 94 | # the puppet environments will be removed if they have 95 | # not been modified in the last 7 days 96 | # when the purge runs (every 24 hours) 97 | def expire(purge_ttl) 98 | expired_time = Time.now - purge_ttl 99 | cache_dir_mutex.with_write_lock do 100 | Dir.glob(File.join(cache_dir, '*')).select { |f| File.directory?(f) }.each do |dir| 101 | if File.mtime(dir) < expired_time 102 | FileUtils.remove_dir(dir) 103 | end 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /developer-docs/api.md: -------------------------------------------------------------------------------- 1 | # ACE API 2 | 3 | ## Overview 4 | ACE provides 2 APIs to enable the execution of Tasks and Catalog compilation for a remote target. 5 | 6 | ## API Endpoints 7 | Each API endpoint accepts a request as described below. The request body must be a JSON object. 8 | 9 | ### POST /run_task 10 | - `target`: [RSAPI Transport Object](#rsapi-transport-object), *required* - Target information to run task on. 11 | - `task`: [Task Object](#task-object), *required* - Task to run on target. 12 | - `parameters`: Object, *optional* - JSON formatted parameters to be provided to task. 13 | 14 | For example, the following runs the 'commit' task on `fw.example.net`: 15 | ``` 16 | { 17 | "target":{ 18 | "remote-transport":"panos", 19 | "host":"fw.example.net", 20 | "user":"foo", 21 | "password":"wibble" 22 | }, 23 | "task":{ 24 | "metadata":{}, 25 | "name":"panos::commit", 26 | "files":[ 27 | { 28 | "filename":"commit.rb", 29 | "sha256":"c5abefbdecee006bd65ef6f625e73f0ebdd1ef3f1b8802f22a1b9644a516ce40", 30 | "size_bytes":640, 31 | "uri":{ 32 | "path":"/puppet/v3/file_content/tasks/panos/commit.rb", 33 | "params":{ 34 | "environment":"production" 35 | } 36 | } 37 | } 38 | ] 39 | }, 40 | "parameters":{ 41 | "message":"Hello world" 42 | } 43 | } 44 | ``` 45 | 46 | #### Response 47 | If the task runs the response will have status 200. 48 | The response will be a standard bolt Result JSON object. 49 | 50 | 51 | ### POST /execute_catalog 52 | - `target`: [RSAPI Transport Object](#rsapi-transport-object), *required* - Target information to execute the catalog on. 53 | - `compiler`: [Compiler Request Object](#compiler-request-object), *required* - Details on the requested compile. 54 | 55 | For example, the following will compile and execute a catalog on fw.example.net: 56 | ``` 57 | { 58 | "target":{ 59 | "remote-transport":"panos", 60 | "host":"fw.example.net", 61 | "user":"foo", 62 | "password":"wibble" 63 | }, 64 | "compiler":{ 65 | "certname":"fw.example.net", 66 | "environment":"development", 67 | "transaction_uuid":"", 68 | "job_id":"" 69 | } 70 | } 71 | ``` 72 | 73 | For pre-Transport devices (like F5), a uri can be sent: 74 | 75 | ``` 76 | { 77 | "target":{ 78 | "remote-transport":"f5", 79 | "uri":"https://foo:wibble@f5.example.net/" 80 | }, 81 | "compiler":{ 82 | "certname":"f5.example.net", 83 | "environment":"development", 84 | "transaction_uuid":"", 85 | "job_id":"" 86 | } 87 | } 88 | ``` 89 | 90 | #### Response 91 | TBD based on orchestrator's needs for feedback. 92 | 93 | ## Data Object Definitions 94 | 95 | ### RSAPI Transport Object 96 | The `target` is a JSON object which reflects the schema of the `remote-transport`. 97 | e.g. If `remote-transport` is `panos`, the object should validate against the panos transport schema. 98 | 99 | Read more about [Transports](https://github.com/puppetlabs/puppet-resource_api#remote-resources) in the Resource API README. The `target` will contain both connection info and bolt's keywords for connection management. 100 | 101 | ### Compiler Request Object 102 | The `compiler` is a JSON object which contains parameters regarding the compilation of the catalog for this request. It contains four attributes that have the same definition as the attributes of the same name in the [puppet server catalog API](https://github.com/puppetlabs/puppetserver/blob/master/documentation/puppet-api/v4/catalog.markdown): 103 | 104 | * `certname` 105 | * `environment` 106 | * `transaction_uuid` 107 | * `job_id` 108 | 109 | The compiler also contains several parameters that can be used in order to help to debug your request, these being: 110 | 111 | * `noop` 112 | * `debug` 113 | * `trace` 114 | * `evaltrace` 115 | 116 | ### Task Object 117 | This is a copy of [bolt's task object](https://github.com/puppetlabs/bolt/blob/master/developer-docs/bolt-api-servers.md#task-object) 118 | 119 | 120 | ## Running ACE 121 | 122 | See the [docker](docker.md) docs on how to run ACE for development. 123 | -------------------------------------------------------------------------------- /lib/ace/puppet_util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openssl' 4 | 5 | module ACE 6 | class PuppetUtil 7 | def self.certificate_revocation 8 | @certificate_revocation ||= begin 9 | Puppet.initialize_settings 10 | result = Puppet[:certificate_revocation] 11 | Puppet.clear 12 | result 13 | end 14 | end 15 | 16 | def self.init_global_settings(ca_cert_path, ca_crls_path, private_key_path, client_cert_path, cachedir, uri) 17 | revocation = certificate_revocation 18 | 19 | Puppet::Util::Log.destinations.clear 20 | Puppet::Util::Log.newdestination(:console) 21 | Puppet.settings[:log_level] = 'notice' 22 | Puppet.settings[:trace] = true 23 | Puppet.settings[:catalog_terminus] = :certless 24 | Puppet.settings[:node_terminus] = :memory 25 | Puppet.settings[:catalog_cache_terminus] = :json 26 | Puppet.settings[:facts_terminus] = :network_device 27 | # the following settings are just to make base_context 28 | # happy, these will not be the final values, 29 | # as per request settings will be set later on 30 | # to satisfy multi-environments 31 | Puppet.settings[:vardir] = cachedir 32 | Puppet.settings[:confdir] = File.join(cachedir, 'conf_x') 33 | Puppet.settings[:rundir] = File.join(cachedir, 'run_x') 34 | Puppet.settings[:logdir] = File.join(cachedir, 'log_x') 35 | Puppet.settings[:codedir] = File.join(cachedir, 'code_x') 36 | Puppet.settings[:plugindest] = File.join(cachedir, 'plugin_x') 37 | 38 | # ssl_context will be a persistent context 39 | cert_provider = Puppet::X509::CertProvider.new( 40 | capath: ca_cert_path, 41 | crlpath: ca_crls_path 42 | ) 43 | ssl_context = Puppet::SSL::SSLProvider.new.create_context( 44 | cacerts: cert_provider.load_cacerts(required: true), 45 | crls: cert_provider.load_crls(required: true), 46 | private_key: OpenSSL::PKey::RSA.new(File.read(private_key_path, encoding: 'utf-8')), 47 | client_cert: OpenSSL::X509::Certificate.new(File.read(client_cert_path, encoding: 'utf-8')), 48 | revocation: revocation 49 | ) 50 | # Store SSL settings for reuse in isolated process 51 | @ssl_settings = { 52 | ssl_context: ssl_context, 53 | server: uri.host, 54 | serverport: uri.port 55 | } 56 | end 57 | 58 | def self.isolated_puppet_settings(certname, environment, enforce_environment, environment_dir) 59 | Puppet.settings[:certname] = certname 60 | Puppet.settings[:environment] = environment 61 | Puppet.settings[:strict_environment_mode] = enforce_environment 62 | 63 | Puppet.settings[:vardir] = File.join(environment_dir) 64 | Puppet.settings[:confdir] = File.join(environment_dir, 'conf') 65 | Puppet.settings[:rundir] = File.join(environment_dir, 'run') 66 | Puppet.settings[:logdir] = File.join(environment_dir, 'log') 67 | Puppet.settings[:codedir] = File.join(environment_dir, 'code') 68 | Puppet.settings[:plugindest] = File.join(environment_dir, 'plugins') 69 | # With puppet 6.14.0 resolvers no longer set :server for pluginfact download, explicitly set them here 70 | Puppet.settings[:server] = @ssl_settings[:server] 71 | Puppet.settings[:masterport] = @ssl_settings[:serverport] 72 | 73 | # establish a base_context. This needs to be the first context on the stack, but must not be created 74 | # before all settings have been set. For example, this will create a Puppet::Environments::Directories 75 | # instance copying the :environmentpath setting and never updating this. 76 | Puppet.push_context(Puppet.base_context(Puppet.settings), "Puppet Initialization") 77 | Puppet.push_context(@ssl_settings, "PuppetServer connection information to be used") 78 | 79 | # finalise settings initialisation 80 | Puppet.settings.use :main, :agent, :ssl 81 | 82 | # special override 83 | Puppet::Transaction::Report.indirection.terminus_class = :rest 84 | 85 | # configure the requested environment, and deploy new loaders 86 | env = Puppet::Node::Environment.remote(environment) 87 | Puppet.push_context({ 88 | configured_environment: environment, 89 | loaders: Puppet::Pops::Loaders.new(env) 90 | }, "Isolated settings to be used") 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/unit/puppet/resource/catalog/certless_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'puppet' 5 | require 'puppet/resource/catalog' 6 | require 'puppet/indirector/rest' 7 | require 'puppet/indirector/catalog/certless' 8 | require 'puppet/http/response' 9 | 10 | RSpec.describe Puppet::Resource::Catalog::Certless do 11 | let(:indirector) { described_class.new } 12 | 13 | let(:request) { instance_double(Puppet::Indirector::Request, 'request') } 14 | let(:session) { instance_double(Puppet::HTTP::Session, 'session') } 15 | let(:compiler) { instance_double(Puppet::HTTP::Service::Compiler, 'compiler') } 16 | let(:environment) { instance_double(Puppet::Node::Environment::Remote, 'environment') } 17 | 18 | let(:request_options) do 19 | { 20 | transport_facts: { 21 | clientcert: "foo.delivery.puppetlabs.net", 22 | clientversion: "6.4.0", 23 | clientnoop: false 24 | }, 25 | trusted_facts: { 26 | authenticated: "remote", 27 | extensions: {}, 28 | certname: "foo.delivery.puppetlabs.net", 29 | hostname: "foo", 30 | domain: "delivery.puppetlabs.net" 31 | }, 32 | fail_on_404: true, 33 | transaction_uuid: "2078748407702309438222210184383400900", 34 | job_id: "271036342116034393375846637943780463672" 35 | } 36 | end 37 | 38 | let(:certname) { 'foo.delivery.puppetlabs.net' } 39 | let(:persistence) { { facts: true, catalog: true } } 40 | let(:facts) { 41 | { 42 | clientcert: 'foo.delivery.puppetlabs.net', 43 | clientnoop: false, 44 | clientversion: '6.4.0' 45 | } 46 | } 47 | 48 | let(:trusted_facts) { 49 | { 50 | authenticated: 'remote', 51 | certname: 'foo.delivery.puppetlabs.net', 52 | domain: 'delivery.puppetlabs.net', 53 | extensions: {}, 54 | hostname: 'foo' 55 | } 56 | } 57 | 58 | let(:opts) { 59 | { 60 | persistence: persistence, 61 | environment: environment.name, 62 | facts: facts, 63 | trusted_facts: trusted_facts, 64 | transaction_uuid: '2078748407702309438222210184383400900', 65 | job_id: '271036342116034393375846637943780463672', 66 | options: { 67 | prefer_requested_environment: false, 68 | capture_logs: false 69 | } 70 | } 71 | } 72 | 73 | let(:uri) { URI.parse('https://www.example.com') } 74 | 75 | describe '#find' do 76 | before do 77 | allow(request).to receive(:key).and_return('foo.delivery.puppetlabs.net') 78 | allow(request).to receive(:environment).and_return(environment) 79 | allow(request).to receive(:options).and_return(request_options) 80 | allow(request).to receive(:server).and_return('localhost') 81 | allow(request).to receive(:port).and_return('9999') 82 | allow(environment).to receive(:name).and_return('environment') 83 | 84 | allow(Puppet).to receive(:lookup).with(:http_session).and_return(session) 85 | allow(session).to receive(:route_to).with(:puppet).and_return(compiler) 86 | end 87 | 88 | it 'returns a Puppet Catalog on success' do 89 | expected_post4_args = [certname, opts] 90 | mocked_post4_return = [nil, Puppet::Resource::Catalog.new('foo.delivery.puppetlabs.net'), []] 91 | 92 | allow(compiler).to receive(:post_catalog4) 93 | .with(*expected_post4_args) 94 | .and_return(mocked_post4_return) 95 | 96 | expect(indirector.find(request)).to be_a Puppet::Resource::Catalog 97 | 98 | expect(compiler).to have_received(:post_catalog4) 99 | .with(*expected_post4_args) 100 | end 101 | 102 | it 'raises error on a 404' do 103 | allow(request).to receive(:options).and_return(request_options) 104 | 105 | # need a Net::HTTP response 106 | uri = URI.parse('https://www.example.com') 107 | stub_request(:post, 'https://www.example.com') 108 | .to_return(status: 404, headers: { "Content-Type" => 'application/json' }) 109 | net_http_response = Net::HTTP.post(uri, '') 110 | puppet_http_response = Puppet::HTTP::ResponseNetHTTP.new(uri, net_http_response) 111 | 112 | allow(compiler).to receive(:post_catalog4).and_raise(Puppet::HTTP::ResponseError.new(puppet_http_response)) 113 | 114 | expect { indirector.find(request) }.to raise_error(Puppet::Error, /resulted in 404 with the message/) 115 | 116 | expect(compiler).to have_received(:post_catalog4) 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/fixtures/ssl/aceserver.cnf: -------------------------------------------------------------------------------- 1 | # 2 | # OpenSSL configuration file. 3 | # This is mostly being used for generation of certificate requests. 4 | # 5 | 6 | # This definition stops the following lines choking if HOME isn't 7 | # defined. 8 | HOME = . 9 | RANDFILE = $ENV::HOME/.rnd 10 | 11 | # Extra OBJECT IDENTIFIER info: 12 | #oid_file = $ENV::HOME/.oid 13 | oid_section = new_oids 14 | 15 | # To use this configuration file with the "-extfile" option of the 16 | # "openssl x509" utility, name here the section containing the 17 | # X.509v3 extensions to use: 18 | # extensions = 19 | # (Alternatively, use a configuration file that has only 20 | # X.509v3 extensions in its main [= default] section.) 21 | 22 | [ new_oids ] 23 | 24 | 25 | #################################################################### 26 | [ req ] 27 | default_bits = 1024 28 | default_keyfile = privkey.pem 29 | distinguished_name = req_distinguished_name 30 | attributes = req_attributes 31 | x509_extensions = v3_ca # The extentions to add to the self signed cert 32 | 33 | # Passwords for private keys if not present they will be prompted for 34 | # input_password = secret 35 | # output_password = secret 36 | 37 | # This sets a mask for permitted string types. There are several options. 38 | # default: PrintableString, T61String, BMPString. 39 | # pkix : PrintableString, BMPString. 40 | # utf8only: only UTF8Strings. 41 | # nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings). 42 | # MASK:XXXX a literal mask value. 43 | # WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings 44 | # so use this option with caution! 45 | string_mask = nombstr 46 | 47 | req_extensions = v3_req # The extensions to add to a certificate request 48 | 49 | [ req_distinguished_name ] 50 | countryName = UK 51 | countryName_default = UK 52 | countryName_min = 2 53 | countryName_max = 2 54 | 55 | stateOrProvinceName = London 56 | stateOrProvinceName_default = London 57 | 58 | localityName = London 59 | 60 | 0.organizationName = Puppet 61 | 0.organizationName_default = Puppet 62 | 63 | commonName = aceserver 64 | commonName_max = 64 65 | 66 | emailAddress = team-network-automation@puppet.com 67 | emailAddress_max = 64 68 | 69 | # SET-ex3 = SET extension number 3 70 | 71 | [ req_attributes ] 72 | challengePassword = A challenge password 73 | challengePassword_min = 4 74 | challengePassword_max = 20 75 | 76 | unstructuredName = An optional company name 77 | 78 | [ usr_cert ] 79 | 80 | # These extensions are added when 'ca' signs a request. 81 | 82 | # This goes against PKIX guidelines but some CAs do it and some software 83 | # requires this to avoid interpreting an end user certificate as a CA. 84 | 85 | basicConstraints=CA:FALSE 86 | 87 | # Here are some examples of the usage of nsCertType. If it is omitted 88 | # the certificate can be used for anything *except* object signing. 89 | 90 | # This is OK for an SSL server. 91 | # nsCertType = server 92 | 93 | # For an object signing certificate this would be used. 94 | # nsCertType = objsign 95 | 96 | # For normal client use this is typical 97 | # nsCertType = client, email 98 | 99 | # and for everything including object signing: 100 | # nsCertType = client, email, objsign 101 | 102 | # This is typical in keyUsage for a client certificate. 103 | # keyUsage = nonRepudiation, digitalSignature, keyEncipherment 104 | 105 | # This will be displayed in Netscape's comment listbox. 106 | nsComment = "OpenSSL Generated Certificate" 107 | 108 | # PKIX recommendations harmless if included in all certificates. 109 | subjectKeyIdentifier=hash 110 | authorityKeyIdentifier=keyid,issuer 111 | 112 | # This stuff is for subjectAltName and issuerAltname. 113 | # Import the email address. 114 | # subjectAltName=email:copy 115 | # An alternative to produce certificates that aren't 116 | # deprecated according to PKIX. 117 | # subjectAltName=email:move 118 | 119 | # Copy subject details 120 | # issuerAltName=issuer:copy 121 | 122 | #nsCaRevocationUrl = http://www.domain.dom/ca-crl.pem 123 | #nsBaseUrl 124 | #nsRevocationUrl 125 | #nsRenewalUrl 126 | #nsCaPolicyUrl 127 | #nsSslServerName 128 | 129 | [ v3_req ] 130 | 131 | # Extensions to add to a certificate request 132 | 133 | basicConstraints = CA:FALSE 134 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment 135 | subjectAltName = @alt_names 136 | 137 | [ alt_names ] 138 | DNS.1 = localhost 139 | DNS.2 = aceserver 140 | DNS.3 = ace_aceserver_1 141 | DNS.4 = spec_puppetserver_1 142 | DNS.5 = ace_server 143 | DNS.6 = puppet_server 144 | DNS.7 = spec_aceserver_1 145 | DNS.8 = puppetdb 146 | DNS.9 = spec_puppetdb_1 147 | DNS.9 = 0.0.0.0 148 | -------------------------------------------------------------------------------- /spec/unit/ace/file_mutex_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/file_mutex' 5 | require 'timeout' 6 | 7 | RSpec.describe ACE::FileMutex do 8 | let(:lock_file) { 'spec/fixtures/test.lock' } 9 | let(:mutex) { described_class.new(lock_file) } 10 | let(:lock_content) { [] } 11 | 12 | before do 13 | File.delete(lock_file) if File.exist?(lock_file) 14 | end 15 | 16 | after do 17 | File.delete(lock_file) if File.exist?(lock_file) 18 | end 19 | 20 | describe '#with_read_lock' do 21 | it 'successully creates a file lock' do 22 | foo = mutex.with_read_lock do 23 | lock_content 24 | end 25 | 26 | expect(File).to exist(lock_file) 27 | expect(foo).to eq(lock_content) 28 | end 29 | 30 | it 'allows for multiple shared (read) locks to access the content' do 31 | p1_in_read, _p1_in_write = IO.pipe 32 | p1_out_read, p1_out_write = IO.pipe 33 | p2_in_read, _p2_in_write = IO.pipe 34 | p2_out_read, p2_out_write = IO.pipe 35 | 36 | Timeout.timeout(2) do 37 | fork do 38 | mutex.with_read_lock do 39 | p1_out_write.puts 'process 1 written' 40 | p1_out_write.close 41 | 42 | # wait inside the lock 43 | puts p1_in_read.read 44 | end 45 | end 46 | fork do 47 | mutex.with_read_lock do 48 | p2_out_write.puts 'process 2 written' 49 | p2_out_write.close 50 | 51 | # wait inside the lock 52 | puts p2_in_read.read 53 | end 54 | end 55 | # close the blocking reads 56 | p1_in_read.close 57 | p2_in_read.close 58 | 59 | # now check that each fork performed the operations at the same time 60 | expect(p1_out_read.gets).to eq("process 1 written\n") 61 | expect(p2_out_read.gets).to eq("process 2 written\n") 62 | end 63 | 64 | # tidy up 65 | p1_out_read.close 66 | p2_out_read.close 67 | end 68 | end 69 | 70 | describe '#with_write_lock' do 71 | it 'successfully creates a file lock' do 72 | foo = mutex.with_write_lock do 73 | lock_content 74 | end 75 | 76 | expect(File).to exist(lock_file) 77 | expect(foo).to eq(lock_content) 78 | end 79 | 80 | context 'when an exclusive (write) lock is held' do 81 | it 'will block the shared (read) lock access' do 82 | p1_in_read, _p1_in_write = IO.pipe 83 | p1_out_read, p1_out_write = IO.pipe 84 | p2_in_read, _p2_in_write = IO.pipe 85 | p2_out_read, p2_out_write = IO.pipe 86 | 87 | # this should perform its operation 88 | fork do 89 | mutex.with_write_lock do 90 | p1_out_write.puts 'process 1 written' 91 | p1_out_write.close 92 | 93 | # wait inside the lock 94 | puts p1_in_read.read 95 | end 96 | end 97 | 98 | expect do 99 | # this should block 100 | Timeout.timeout(1) do 101 | fork do 102 | mutex.with_write_lock do 103 | p2_out_write.puts 'process 2 should not write' 104 | p2_out_write.close 105 | 106 | # wait inside the lock 107 | puts p2_in_read.read 108 | end 109 | end 110 | 111 | # these will force the mutex 112 | p1_out_read.gets 113 | p2_out_read.gets 114 | end 115 | end.to raise_error Timeout::Error 116 | 117 | # close the blocking reads 118 | p1_in_read.close 119 | p2_in_read.close 120 | end 121 | end 122 | end 123 | 124 | context 'when multiple exclusive (write) locks are requested' do 125 | it 'will properly release the exclusive lock to enable new locks to be acquired' do 126 | p1_out_read, p1_out_write = IO.pipe 127 | p2_out_read, p2_out_write = IO.pipe 128 | p3_out_read, p3_out_write = IO.pipe 129 | p4_out_read, p4_out_write = IO.pipe 130 | 131 | fork do 132 | mutex.with_write_lock do 133 | p3_out_write.puts 'process 3 written' 134 | p3_out_write.close 135 | end 136 | end 137 | fork do 138 | mutex.with_write_lock do 139 | p1_out_write.puts 'process 1 written' 140 | p1_out_write.close 141 | end 142 | end 143 | fork do 144 | mutex.with_write_lock do 145 | p4_out_write.puts 'process 4 written' 146 | p4_out_write.close 147 | end 148 | end 149 | fork do 150 | mutex.with_write_lock do 151 | p2_out_write.puts 'process 2 written' 152 | p2_out_write.close 153 | end 154 | end 155 | 156 | # now check that each fork performed the operations at the same time 157 | expect(p1_out_read.gets).to eq("process 1 written\n") 158 | expect(p2_out_read.gets).to eq("process 2 written\n") 159 | expect(p3_out_read.gets).to eq("process 3 written\n") 160 | expect(p4_out_read.gets).to eq("process 4 written\n") 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/acceptance/ace/transport_app_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/config' 5 | require 'ace/transport_app' 6 | require 'faraday' 7 | require 'openssl' 8 | require 'rack/test' 9 | 10 | module Acceptance 11 | class SUT 12 | attr_reader :app 13 | 14 | def self.app 15 | base_config = { 16 | "ssl-cert" => "spec/volumes/puppet/ssl/certs/aceserver.pem", 17 | "ssl-key" => "spec/volumes/puppet/ssl/private_keys/aceserver.pem", 18 | "ssl-ca-cert" => "spec/volumes/puppet/ssl/certs/ca.pem", 19 | "ssl-ca-crls" => "spec/volumes/puppet/ssl/ca/ca_crl.pem", 20 | "puppet-server-uri" => "https://0.0.0.0:8140", 21 | "loglevel" => "debug", 22 | "tmp-dir" => "tmp/", 23 | "cache-dir" => "tmp/cache", 24 | "host" => "0.0.0.0" 25 | } 26 | config = ACE::Config.new(base_config) 27 | config.make_compatible 28 | config.validate 29 | 30 | @app ||= ACE::TransportApp.new(config) 31 | end 32 | end 33 | end 34 | 35 | RSpec.describe ACE::TransportApp do 36 | include Rack::Test::Methods 37 | include Acceptance 38 | 39 | before do 40 | # see: https://github.com/bblimke/webmock#connecting-on-nethttpstart 41 | WebMock.allow_net_connect!(net_http_connect_on_start: true) 42 | end 43 | 44 | after do 45 | WebMock.disable_net_connect! 46 | end 47 | 48 | def app 49 | # Ensure only one instance of the application runs 50 | Acceptance::SUT.app 51 | end 52 | 53 | ################## 54 | # Catalog Endpoint 55 | ################## 56 | describe '/execute_catalog' do 57 | let(:execute_catalog_body) do 58 | { 59 | "target": { 60 | "remote-transport": "spinner" 61 | }, 62 | "timeout": 20, 63 | "compiler": { 64 | "certname": "localhost", 65 | "environment": environment, 66 | "enforce_environment": enforce_environment, 67 | "transaction_uuid": "2d931510-d99f-494a-8c67-87feb05e1594", 68 | "job_id": "1" 69 | } 70 | } 71 | end 72 | 73 | before { post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' } 74 | 75 | context 'when enforcing a matching environment' do 76 | let(:environment) { "production" } 77 | let(:enforce_environment) { true } 78 | 79 | it { expect(last_response.errors).to match(/\A\Z/) } 80 | it { expect(last_response).to be_ok } 81 | it { expect(last_response.status).to eq(200) } 82 | it { expect(JSON.parse(last_response.body)['certname']).to eq('localhost') } 83 | it { expect(JSON.parse(last_response.body)['status']).to eq('unchanged') } 84 | end 85 | 86 | context 'when enforcing a non-matching environment' do 87 | let(:environment) { "something_else" } 88 | let(:enforce_environment) { true } 89 | 90 | it { expect(last_response.errors).to match(/\A\Z/) } 91 | it { expect(last_response).to be_ok } 92 | it { expect(last_response.status).to eq(200) } 93 | it { expect(JSON.parse(last_response.body)['certname']).to eq('localhost') } 94 | it { expect(JSON.parse(last_response.body)['status']).to eq('failed') } 95 | end 96 | 97 | context 'when not enforcing a environment' do 98 | let(:environment) { "" } 99 | let(:enforce_environment) { false } 100 | 101 | it { expect(last_response.errors).to match(/\A\Z/) } 102 | it { expect(last_response).to be_ok } 103 | it { expect(last_response.status).to eq(200) } 104 | it { expect(JSON.parse(last_response.body)['certname']).to eq('localhost') } 105 | it { expect(JSON.parse(last_response.body)['status']).to eq('unchanged') } 106 | end 107 | 108 | context 'when enforcing an environment and not providing an environment' do 109 | let(:environment) { "" } 110 | let(:enforce_environment) { true } 111 | 112 | it { expect(last_response.errors).to match(/\A\Z/) } 113 | it { expect(last_response).not_to be_ok } 114 | it { expect(last_response.status).to eq(400) } 115 | it { expect(JSON.parse(last_response.body)['status']).to eq('failure') } 116 | it { expect(JSON.parse(last_response.body)['result']['_error']['kind']).to eq('puppetlabs/ace/execute_catalog') } 117 | 118 | it { 119 | expect(JSON.parse(last_response.body)['result']['_error']['msg']).to eq( 120 | 'You MUST provide an `environment` when `enforce_environment` is set to true' 121 | ) 122 | } 123 | end 124 | end 125 | 126 | ################## 127 | # Task Endpoint 128 | ################## 129 | transports = ['spinner', 'spinner_transport'] 130 | transports.each do |transport| 131 | describe "/run_task for #{transport}" do 132 | let(:task_metadata) { 133 | Faraday.new( 134 | url: 'https://0.0.0.0:8140/puppet/v3/tasks/test_device/device_spin?environment=production', 135 | ssl: { 136 | client_cert: OpenSSL::X509::Certificate.new(File.read('spec/volumes/puppet/ssl/certs/aceserver.pem')), 137 | client_key: OpenSSL::PKey::RSA.new(File.read('spec/volumes/puppet/ssl/private_keys/aceserver.pem')), 138 | ca_file: 'spec/volumes/puppet/ssl/certs/ca.pem' 139 | } 140 | ) 141 | } 142 | 143 | let(:task_body) do 144 | response = task_metadata.get 145 | JSON.parse(response.body) 146 | end 147 | 148 | let(:run_task_body) do 149 | { 150 | "task": task_body, 151 | "target": { 152 | "remote-transport": transport 153 | }, 154 | "parameters": { 155 | "cpu_time": 1, 156 | "wait_time": 1 157 | } 158 | } 159 | end 160 | 161 | describe 'success' do 162 | it 'returns 200 with `success` status' do 163 | post '/run_task', JSON.generate(run_task_body), 'CONTENT_TYPE' => 'text/json' 164 | 165 | expect(last_response.errors).to match(/\A\Z/) 166 | expect(last_response).to be_ok 167 | expect(last_response.status).to eq(200) 168 | result = JSON.parse(last_response.body) 169 | expect(result['status']).to eq('success') 170 | end 171 | end 172 | 173 | describe 'timeout' do 174 | it 'times out when the supplied timeout is hit' do 175 | body = run_task_body.merge({ 'timeout' => 2, 'parameters': { 'cpu_time' => 5, 'wait_time' => 5 } }) 176 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 177 | result = JSON.parse(last_response.body) 178 | expect(result['status']).to eq('failure') 179 | error_message = result.dig('value', '_error', 'msg') 180 | expect(error_message).to eq('Task execution on remote timed out after 2 seconds') 181 | end 182 | end 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /developer-docs/docker.md: -------------------------------------------------------------------------------- 1 | # Docker Docs 2 | 3 | ## Setup 4 | 5 | [Docker-compose installation](https://docs.docker.com/compose/install/) would need to be followed and setup in order to use the ACE containers for development. 6 | 7 | The ACE service is dependent on having access to a Puppetserver and a PuppetDB, these are included as Docker containers. Navigate to the `spec/` folder and build the Puppetserver and PuppetDB containers using the following command. 8 | 9 | ``` 10 | docker-compose up -d --build 11 | ``` 12 | 13 | The Puppetserver will take some time to start and typically using the following command to verify that it is ready: 14 | 15 | ``` 16 | docker logs --follow spec_puppet_1 17 | ``` 18 | 19 | Once the Puppetserver is ready, the following message is reported: 20 | 21 | ``` 22 | 2019-03-18 15:42:19,964 INFO [p.s.m.master-service] Puppet Server has successfully started and is now ready to handle requests 23 | 2019-03-18 15:42:19,965 INFO [p.s.l.legacy-routes-service] The legacy routing service has successfully started and is now ready to handle requests 24 | ``` 25 | 26 | On Linux, ensure that you have access to all volumes: 27 | 28 | ``` 29 | sudo chmod a+rx -R volumes/ 30 | ``` 31 | 32 | At this point it is required to generate certs for the `aceserver`, this can be achieved though: 33 | 34 | ``` 35 | docker exec spec_puppet_1 puppetserver ca generate --certname aceserver --subject-alt-names localhost,aceserver,ace_aceserver_1,spec_puppetserver_1,ace_server,puppet_server,spec_aceserver_1,puppetdb,spec_puppetdb_1,0.0.0.0,puppet,spec_puppet_1,ace_aceserver_1 36 | ``` 37 | 38 | On Linux, ensure that you have access to the newly created files: 39 | 40 | ``` 41 | sudo chmod a+rx -R volumes/ 42 | ``` 43 | 44 | Reasoning for this is that it makes it easier to ensure that the cert names are consistent across environments. 45 | 46 | Once the containers in the `spec/` directory are running, the ACE container can be launched by executing the following command within the root of the project: 47 | 48 | ``` 49 | docker-compose up -d --build 50 | ``` 51 | 52 | _Note_: If the `aceserver` certificate needs regenerated the following steps can be performed: 53 | 54 | ``` 55 | docker exec spec_puppet_1 puppetserver ca revoke --certname aceserver 56 | docker exec spec_puppet_1 rm /etc/puppetlabs/puppet/ssl/certs/aceserver.pem /etc/puppetlabs/puppet/ssl/private_keys/aceserver.pem /etc/puppetlabs/puppet/ssl/public_keys/aceserver.pem /etc/puppetlabs/puppet/ssl/ca/signed/aceserver.pem 57 | ``` 58 | 59 | And then generate the certificate again using the `ca generate` command from above. 60 | 61 | ## Verifying the services 62 | 63 | [Postman](https://www.getpostman.com/) is advisable to verify that the endpoints are configured. In order to set up Postman, navigate to Settings > Certificates and add client certificates for hosts `0.0.0.0:8140` and `0.0.0.0:44633` where the CRT file points to `spec/volumes/puppet/ssl/certs/aceserver.pem` and Key file points to `spec/volumes/puppet/ssl/private_keys/aceserver.pem` 64 | 65 | *Note*: These cert and key files will only be created when the PuppetServer container has finished initalising and the `ca generate` command has been used. 66 | 67 | ### PuppetServer /tasks/:module/:task 68 | 69 | ``` 70 | https://0.0.0.0:8140/puppet/v3/tasks/:module/:task?environment=production 71 | ``` 72 | 73 | Is the endpoint to get the task metadata from a PuppetServer, i.e. 74 | 75 | ``` 76 | GET https://0.0.0.0:8140/puppet/v3/tasks/panos/apikey?environment=production 77 | 78 | RESPONSE 79 | { 80 | "metadata": { 81 | "description": "Retrieve a PAN-OS apikey", 82 | "files": [ 83 | ... 84 | ], 85 | "parameters": {}, 86 | "puppet_task_version": 1, 87 | "remote": true, 88 | "supports_noop": false 89 | }, 90 | "name": "panos::apikey", 91 | "files": [ 92 | ... 93 | ] 94 | } 95 | ``` 96 | 97 | This can be used to construct the request body that will be used to execute the [ACE `/run_task`](#ace-runtask) endpoint. 98 | 99 | ### ACE /run_task 100 | 101 | ``` 102 | POST https://0.0.0.0:44633/run_task 103 | BODY { 104 | "target": { 105 | "remote-transport": "panos", 106 | "name":"pavm", 107 | "hostname": "vvtzckq3vzx995w.delivery.puppetlabs.net", 108 | "user": "admin", 109 | "password": "admin", 110 | "ssl": false 111 | }, 112 | "task": { 113 | "metadata": { 114 | "description": "Retrieve a PAN-OS apikey", 115 | "files": [ 116 | ... 117 | ], 118 | "parameters": {}, 119 | "puppet_task_version": 1, 120 | "remote": true, 121 | "supports_noop": false 122 | }, 123 | "name": "panos::apikey", 124 | "files": [ 125 | ... 126 | ] 127 | }} 128 | 129 | RESPONSE 130 | { 131 | "node": "vvtzckq3vzx995w.delivery.puppetlabs.net", 132 | "status": "success", 133 | "result": { 134 | "apikey": "LUFRPT14MW5xOEo1R09KVlBZNnpnemh0VHRBOWl6TGM9bXcwM3JHUGVhRlNiY0dCR0srNERUQT09" 135 | } 136 | } 137 | ``` 138 | 139 | Running the containers through Docker does have the benefit that the containers will be a better representation of how the ACE service will work in PE, however for developing and verifying changes it can be considered slow as changes may require the ACE container to be rebuilt which can take some time, an alternative approach for local development is [running the service locally](#running-ace-locally), this way the Puppetserver and PuppetDB containers are only required to be running. 140 | 141 | ## Running ACE locally 142 | 143 | The ACE service can also be ran directly through Puma rather than building the container, this has the benefits of being able to specifying local changes of Bolt within the Gemfile rather than having to make the changes in the container, or committing the changes and rebuilding the containers which can take some time. 144 | 145 | When running locally through Puma there is a caveat on the tasks that are being executed and a possible conflict with the version of Ruby on the local installation, where solutions are highlighted in the [incorrect Puppet Ruby version](#incorrect-puppet-ruby-version). 146 | 147 | Launching the service locally can be achieved by running the following: 148 | 149 | ``` 150 | ACE_CONF=config/local.conf bundle exec puma -C config/transport_tasks_config.rb 151 | ``` 152 | 153 | ### Incorrect Puppet Ruby version 154 | 155 | The tasks typically used in networking modules have a shebang referencing the Puppet Ruby, there are two approaches to getting around this. 156 | 157 | When constructing requests to be sent to ACE, in the target hash the interpreter can be specified, i.e. 158 | 159 | ``` 160 | { 161 | "target":{ 162 | "remote-transport":"panos", 163 | "name":"pavm", 164 | "interpreters":{ 165 | ".rb":"/Users/thomas.franklin/.rbenv/versions/2.5.1/bin/ruby" 166 | } 167 | }, 168 | "task": {hash from puppetserver /tasks endpoint} 169 | } 170 | ``` 171 | 172 | Although this would need to be included in every request - a 'permanent' solution would be to symlink the Puppet Ruby to the development version of Ruby, i.e. 173 | 174 | ``` 175 | sudo ln -svf $(bundle exec which ruby) /opt/puppetlabs/puppet/bin/ruby 176 | ``` 177 | -------------------------------------------------------------------------------- /spec/unit/ace/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/config' 5 | 6 | RSpec.describe ACE::Config do 7 | def build_config(config_file, from_env = false) 8 | config = ACE::Config.new 9 | config.load_file_config(config_file) 10 | config.load_env_config if from_env 11 | config.validate 12 | config 13 | end 14 | 15 | let(:configdir) { File.join(__dir__, '../../', 'fixtures', 'api_server_configs') } 16 | let(:globalconfig) { File.join(configdir, 'global-ace-server.conf') } 17 | let(:requiredconfig) { File.join(configdir, 'required-ace-server.conf') } 18 | let(:base_config) { Hocon.load(requiredconfig)['ace-server'] } 19 | 20 | let(:complete_config_keys) { 21 | ['host', 'port', 'ssl-cert', 'ssl-key', 'ssl-ca-cert', 22 | 'ssl-cipher-suites', 'loglevel', 'logfile', 'allowlist', 'projects-dir', 23 | 'concurrency', 'cache-dir', 'puppet-server-conn-timeout', 24 | 'puppet-server-uri', 'ssl-ca-crls', 'environments-codedir', 25 | 'environmentpath', 'basemodulepath'].freeze 26 | } 27 | 28 | let(:complete_env_keys) { 29 | ['ssl-cert', 'ssl-key', 'ssl-ca-cert', 'loglevel', 30 | 'concurrency', 'puppet-server-conn-timeout', 31 | 'puppet-server-uri', 'ssl-ca-crls'] 32 | } 33 | 34 | let(:complete_ssl_keys) { 35 | ['ssl-cert', 'ssl-key', 'ssl-ca-cert', 'ssl-ca-crls'] 36 | } 37 | 38 | let(:complete_required_keys) { 39 | ['ssl-cert', 'ssl-key', 'ssl-ca-cert', 'ssl-ca-crls', 'puppet-server-uri', 'cache-dir'] 40 | } 41 | 42 | let(:complete_defaults) { 43 | { 'host' => '127.0.0.1', 44 | 'loglevel' => 'warn', 45 | 'ssl-cipher-suites' => ['ECDHE-ECDSA-AES256-GCM-SHA384', 46 | 'ECDHE-RSA-AES256-GCM-SHA384', 47 | 'ECDHE-ECDSA-CHACHA20-POLY1305', 48 | 'ECDHE-RSA-CHACHA20-POLY1305', 49 | 'ECDHE-ECDSA-AES128-GCM-SHA256', 50 | 'ECDHE-RSA-AES128-GCM-SHA256', 51 | 'ECDHE-ECDSA-AES256-SHA384', 52 | 'ECDHE-RSA-AES256-SHA384', 53 | 'ECDHE-ECDSA-AES128-SHA256', 54 | 'ECDHE-RSA-AES128-SHA256'], 55 | 'port' => 44633, 56 | 'concurrency' => 10, 57 | 'cache-dir' => "/opt/puppetlabs/server/data/ace-server/cache", 58 | 'puppet-server-conn-timeout' => 120, 59 | 'file-server-conn-timeout' => 120 } 60 | } 61 | 62 | it 'returns config_keys as an array' do 63 | expect(described_class.new.config_keys).to be_a(Array) 64 | end 65 | 66 | # These tests provide us with insight should the values from the Bolt controlled 67 | # base class change. 68 | it 'config_keys contains the expected base keys' do 69 | expect(described_class.new.config_keys.sort).to eq(complete_config_keys.sort) 70 | end 71 | 72 | it 'returns env_keys as an array' do 73 | expect(described_class.new.env_keys).to be_a(Array) 74 | end 75 | 76 | it 'env_keys contains the expected base keys' do 77 | expect(described_class.new.env_keys).to eq(complete_env_keys) 78 | end 79 | 80 | it 'returns int_keys as an array' do 81 | expect(described_class.new.int_keys).to be_a(Array) 82 | end 83 | 84 | it 'returns defaults as a hash' do 85 | expect(described_class.new.defaults).to be_a(Hash) 86 | end 87 | 88 | it 'defaults contains the expected base defaults' do 89 | expect(described_class.new.defaults).to eq(complete_defaults) 90 | end 91 | 92 | it 'returns required_keys as an array' do 93 | expect(described_class.new.required_keys).to be_a(Array) 94 | end 95 | 96 | it 'required_keys contains the expected base keys' do 97 | expect(described_class.new.required_keys).to eq(complete_required_keys) 98 | end 99 | 100 | context 'with configuation parameters set in environment variables' do 101 | def transform_key(key) 102 | "ACE_#{key.tr('-', '_').upcase}" 103 | end 104 | 105 | before(:context) do # ENV is global state needed to be manually cleaned # rubocop:disable RSpec/BeforeAfterAll 106 | empty = described_class.new 107 | empty.env_keys.each do |key| 108 | transformed_key = transform_key(key) 109 | ENV[transformed_key] = if empty.int_keys.include?(key) 110 | '23' 111 | else 112 | __FILE__ 113 | end 114 | end 115 | end 116 | 117 | let(:fake_env_config) { __FILE__ } 118 | let(:config) { build_config(globalconfig, true) } 119 | 120 | after(:context) do # ENV is global state needed to be manually cleaned # rubocop:disable RSpec/BeforeAfterAll 121 | described_class.new.env_keys.each do |key| 122 | ENV.delete(transform_key(key)) 123 | end 124 | end 125 | 126 | it 'reads ssl-cert ' do 127 | expect(config['ssl-cert']).to eq(fake_env_config) 128 | end 129 | 130 | it 'reads ssl-key' do 131 | expect(config['ssl-key']).to eq(fake_env_config) 132 | end 133 | 134 | it 'reads ssl-ca-cert' do 135 | expect(config['ssl-ca-cert']).to eq(fake_env_config) 136 | end 137 | 138 | it 'reads loglevel' do 139 | expect(config['loglevel']).to eq(fake_env_config) 140 | end 141 | 142 | it 'reads concurrency' do 143 | expect(config['concurrency']).to eq(23) 144 | end 145 | 146 | it 'reads puppet-server-conn-timeout' do 147 | expect(config['puppet-server-conn-timeout']).to eq(23) 148 | end 149 | 150 | it 'reads puppet-server-uri' do 151 | expect(config['puppet-server-uri']).to eq(fake_env_config) 152 | end 153 | end 154 | 155 | it "errors when concurrency is not an integer" do 156 | expect { 157 | described_class.new(base_config.merge('concurrency' => '10')).validate 158 | }.to raise_error(Bolt::ValidationError, "Configured 'concurrency' must be a positive integer") 159 | end 160 | 161 | it "errors when concurrency is zero" do 162 | expect { 163 | described_class.new(base_config.merge('concurrency' => 0)).validate 164 | }.to raise_error(Bolt::ValidationError, "Configured 'concurrency' must be a positive integer") 165 | end 166 | 167 | it "errors when concurrency is negative" do 168 | expect { 169 | described_class.new(base_config.merge('concurrency' => -1)).validate 170 | }.to raise_error(Bolt::ValidationError, "Configured 'concurrency' must be a positive integer") 171 | end 172 | 173 | it "errors when puppet-server-conn-timeout is not an integer" do 174 | expect { 175 | described_class.new(base_config.merge('puppet-server-conn-timeout' => '120')).validate 176 | }.to raise_error(Bolt::ValidationError, "Configured 'puppet-server-conn-timeout' must be a positive integer") 177 | end 178 | 179 | it "contains compatible file-server keys for use with bolt" do 180 | instance = described_class.new(base_config) 181 | instance.make_compatible 182 | config_data = instance.instance_variable_get(:@data) 183 | 184 | expect(config_data).to be_key('file-server-uri') 185 | expect(config_data['file-server-uri']).to eq(config_data['puppet-server-uri']) 186 | expect(config_data).to be_key('file-server-conn-timeout') 187 | expect(config_data['file-server-conn-timeout']).to eq(config_data['puppet-server-conn-timeout']) 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /spec/fixtures/conf/auth.conf: -------------------------------------------------------------------------------- 1 | authorization: { 2 | version: 1 3 | rules: [ 4 | { 5 | # Allow nodes to retrieve their own catalog 6 | match-request: { 7 | path: "^/puppet/v3/catalog/([^/]+)$" 8 | type: regex 9 | method: [get, post] 10 | } 11 | allow: "$1" 12 | sort-order: 500 13 | name: "puppetlabs v3 catalog from agents" 14 | }, 15 | { 16 | # Allow services to retrieve catalogs on behalf of others 17 | match-request: { 18 | path: "^/puppet/v4/catalog/?$" 19 | type: regex 20 | method: post 21 | } 22 | allow: "*" 23 | sort-order: 500 24 | name: "puppetlabs v4 catalog for services" 25 | }, 26 | { 27 | # Allow nodes to retrieve the certificate they requested earlier 28 | match-request: { 29 | path: "/puppet-ca/v1/certificate/" 30 | type: path 31 | method: get 32 | } 33 | allow-unauthenticated: true 34 | sort-order: 500 35 | name: "puppetlabs certificate" 36 | }, 37 | { 38 | # Allow all nodes to access the certificate revocation list 39 | match-request: { 40 | path: "/puppet-ca/v1/certificate_revocation_list/ca" 41 | type: path 42 | method: get 43 | } 44 | allow-unauthenticated: true 45 | sort-order: 500 46 | name: "puppetlabs crl" 47 | }, 48 | { 49 | # Allow nodes to request a new certificate 50 | match-request: { 51 | path: "/puppet-ca/v1/certificate_request" 52 | type: path 53 | method: [get, put] 54 | } 55 | allow-unauthenticated: true 56 | sort-order: 500 57 | name: "puppetlabs csr" 58 | }, 59 | { 60 | # Allow the CA CLI to access the certificate_status endpoint 61 | match-request: { 62 | path: "/puppet-ca/v1/certificate_status" 63 | type: path 64 | method: [get, put, delete] 65 | } 66 | allow: { 67 | extensions: { 68 | pp_cli_auth: "true" 69 | } 70 | } 71 | sort-order: 500 72 | name: "puppetlabs cert status" 73 | }, 74 | { 75 | # Allow the CA CLI to access the certificate_statuses endpoint 76 | match-request: { 77 | path: "/puppet-ca/v1/certificate_statuses" 78 | type: path 79 | method: get 80 | } 81 | allow: { 82 | extensions: { 83 | pp_cli_auth: "true" 84 | } 85 | } 86 | sort-order: 500 87 | name: "puppetlabs cert statuses" 88 | }, 89 | { 90 | # Allow unauthenticated access to the status service endpoint 91 | match-request: { 92 | path: "/status/v1/services" 93 | type: path 94 | method: get 95 | } 96 | allow-unauthenticated: true 97 | sort-order: 500 98 | name: "puppetlabs status service - full" 99 | }, 100 | { 101 | match-request: { 102 | path: "/status/v1/simple" 103 | type: path 104 | method: get 105 | } 106 | allow-unauthenticated: true 107 | sort-order: 500 108 | name: "puppetlabs status service - simple" 109 | }, 110 | { 111 | match-request: { 112 | path: "/puppet/v3/environments" 113 | type: path 114 | method: get 115 | } 116 | allow: "*" 117 | sort-order: 500 118 | name: "puppetlabs environments" 119 | }, 120 | { 121 | # Allow nodes to access all file_bucket_files. Note that access for 122 | # the 'delete' method is forbidden by Puppet regardless of the 123 | # configuration of this rule. 124 | match-request: { 125 | path: "/puppet/v3/file_bucket_file" 126 | type: path 127 | method: [get, head, post, put] 128 | } 129 | allow: "*" 130 | sort-order: 500 131 | name: "puppetlabs file bucket file" 132 | }, 133 | { 134 | # Allow nodes to access all file_content. Note that access for the 135 | # 'delete' method is forbidden by Puppet regardless of the 136 | # configuration of this rule. 137 | match-request: { 138 | path: "/puppet/v3/file_content" 139 | type: path 140 | method: [get, post] 141 | } 142 | allow: "*" 143 | sort-order: 500 144 | name: "puppetlabs file content" 145 | }, 146 | { 147 | # Allow nodes to access all file_metadata. Note that access for the 148 | # 'delete' method is forbidden by Puppet regardless of the 149 | # configuration of this rule. 150 | match-request: { 151 | path: "/puppet/v3/file_metadata" 152 | type: path 153 | method: [get, post] 154 | } 155 | allow: "*" 156 | sort-order: 500 157 | name: "puppetlabs file metadata" 158 | }, 159 | { 160 | # Allow nodes to retrieve only their own node definition 161 | match-request: { 162 | path: "^/puppet/v3/node/([^/]+)$" 163 | type: regex 164 | method: get 165 | } 166 | allow: "$1" 167 | sort-order: 500 168 | name: "puppetlabs node" 169 | }, 170 | { 171 | # Allow nodes to store their own reports, and ACE to store any reports 172 | match-request: { 173 | path: "^/puppet/v3/report/([^/]+)$" 174 | type: regex 175 | method: put 176 | } 177 | allow: [ "$1", "aceserver" ] 178 | sort-order: 500 179 | name: "puppetlabs report" 180 | }, 181 | { 182 | # Allow nodes to update their own facts 183 | match-request: { 184 | path: "^/puppet/v3/facts/([^/]+)$" 185 | type: regex 186 | method: put 187 | } 188 | allow: "$1" 189 | sort-order: 500 190 | name: "puppetlabs facts" 191 | }, 192 | { 193 | match-request: { 194 | path: "/puppet/v3/status" 195 | type: path 196 | method: get 197 | } 198 | allow-unauthenticated: true 199 | sort-order: 500 200 | name: "puppetlabs status" 201 | }, 202 | { 203 | match-request: { 204 | path: "/puppet/v3/static_file_content" 205 | type: path 206 | method: get 207 | } 208 | allow: "*" 209 | sort-order: 500 210 | name: "puppetlabs static file content" 211 | }, 212 | { 213 | match-request: { 214 | path: "/puppet/v3/tasks" 215 | type: path 216 | } 217 | allow: "*" 218 | sort-order: 500 219 | name: "puppet tasks information" 220 | }, 221 | { 222 | # Deny everything else. This ACL is not strictly 223 | # necessary, but illustrates the default policy 224 | match-request: { 225 | path: "/" 226 | type: path 227 | } 228 | deny: "*" 229 | sort-order: 999 230 | name: "puppetlabs deny all" 231 | } 232 | ] 233 | } 234 | -------------------------------------------------------------------------------- /spec/unit/ace/plugin_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ace/plugin_cache' 4 | require 'ace/puppet_util' 5 | require 'hocon' 6 | 7 | RSpec.describe ACE::PluginCache do 8 | let(:plugin_cache) { described_class.new('/tmp/environment_cache') } 9 | 10 | let(:puppetserver_directory_path) { '/foo/' } 11 | let(:fake_file_path) { 'fake_file.rb' } 12 | let(:params) { 13 | '?checksum_type=sha256&environment=production&ignore=.hg&links=follow&max_files=-1&recurse=true&source_permissions=' 14 | } 15 | 16 | before do 17 | allow(ACE::ForkUtil).to receive(:isolate).and_yield 18 | end 19 | 20 | describe '#expire' do 21 | before do 22 | allow(Dir).to receive(:glob).with('foo/environment_cache/*').and_return(['foo/environment_cache/production', 23 | 'foo/environment_cache/bar']) 24 | allow(File).to receive(:directory?).with('foo/environment_cache/production').and_return(true) 25 | allow(File).to receive(:directory?).with('foo/environment_cache/bar').and_return(true) 26 | allow(File).to receive(:mtime).with('foo/environment_cache/bar').and_return(Time.now) 27 | end 28 | 29 | it 'removes only directories which have expired' do 30 | allow(File).to receive(:mtime).with('foo/environment_cache/production').and_return(Time.now - 31 | (ACE::PluginCache::PURGE_TTL + 100)) 32 | 33 | allow(FileUtils).to receive(:remove_dir) 34 | described_class.new('foo/environment_cache', purge_interval: 1) 35 | sleep 1 36 | expect(FileUtils).to have_received(:remove_dir).with('foo/environment_cache/production').at_most(2).times 37 | expect(FileUtils).not_to have_received(:remove_dir).with('foo/environment_cache/bar') 38 | end 39 | 40 | it 'does not remove directories when nothing expired' do 41 | allow(File).to receive(:mtime).with('foo/environment_cache/production').and_return(Time.now) 42 | allow(FileUtils).to receive(:remove_dir) 43 | described_class.new('foo/environment_cache') 44 | expect(FileUtils).not_to have_received(:remove_dir) 45 | end 46 | end 47 | 48 | context 'with a mock filesystem' do 49 | before do 50 | allow(FileUtils).to receive(:mkdir_p) 51 | allow(FileUtils).to receive(:cp_r) 52 | allow(FileUtils).to receive(:touch) 53 | allow(FileUtils).to receive(:remove_dir) 54 | allow(ACE::PuppetUtil).to receive(:isolated_puppet_settings) 55 | end 56 | 57 | describe '#setup' do 58 | it { expect(plugin_cache.setup).to be_a(described_class) } 59 | 60 | it "creates the cache-dir" do 61 | plugin_cache.setup 62 | expect(FileUtils).to have_received(:mkdir_p).with('/tmp/environment_cache') 63 | end 64 | end 65 | 66 | describe '#with_synced_libdir' do 67 | it 'isolates the call and yields' do 68 | allow(plugin_cache).to receive(:with_synced_libdir_core).and_yield 69 | 70 | expect { |b| plugin_cache.with_synced_libdir('environment', false, 'certname', nil, &b) }.to yield_with_no_args 71 | expect(ACE::ForkUtil).to have_received(:isolate).ordered 72 | expect(plugin_cache).to have_received(:with_synced_libdir_core).with('environment').ordered 73 | end 74 | end 75 | end 76 | 77 | describe '#sync_core' do 78 | # work around for executing a semi realistic pluginsync 79 | # the ca, crls, keys and certs are just used to pass parsing 80 | # that occurs in puppet 81 | before do 82 | allow(Puppet::Pops::Loaders).to receive(:new) 83 | ACE::PuppetUtil.init_global_settings('spec/fixtures/ssl/ca.pem', 84 | 'spec/fixtures/ssl/crl.pem', 85 | 'spec/fixtures/ssl/key.pem', 86 | 'spec/fixtures/ssl/cert.pem', 87 | '/tmp/environment_cache', 88 | URI.parse('https://localhost:9999')) 89 | FileUtils.mkdir_p('/tmp/environment_cache/production') 90 | ACE::PuppetUtil.isolated_puppet_settings('foo', 'production', false, '/tmp/environment_cache/production') 91 | end 92 | # This example is a ugly tradeoff between more confidence in calling the 93 | # Puppet::Configurer::PluginHandler.new.download_plugins methods and having 94 | # simpler tests. Since we do not have good control or understanding of the 95 | # Puppet API, we opt for the former. 96 | 97 | it 'calls into the puppetserver to download plugins' do 98 | stub_request(:get, "https://localhost:9999/puppet/v3/file_metadatas/pluginfacts#{params}use") 99 | .to_return( 100 | status: 200, 101 | body: '[ 102 | { 103 | "path":"/etc/puppetlabs/code/environments/production/modules", 104 | "relative_path":".","links":"follow","owner":0,"group":0,"mode":493, 105 | "checksum":{"type":"ctime", 106 | "value":"{ctime}2019-03-28 10:53:51 +0000"}, 107 | "type":"directory","destination":null 108 | } 109 | ]', headers: { content_type: 'application/json' } 110 | ) 111 | stub_request(:get, "https://localhost:9999/puppet/v3/file_metadata/pluginfacts") 112 | .to_return(status: 200, body: '{ 113 | "message":"Not Found: Could not find file_metadata pluginfacts", 114 | "issue_kind":"RESOURCE_NOT_FOUND" 115 | }', headers: { content_type: 'application/json' }) 116 | stub_request(:get, "https://localhost:9999/puppet/v3/file_metadatas/plugins#{params}ignore") 117 | .to_return(status: 200, body: "[ 118 | { 119 | \"path\":\"#{puppetserver_directory_path}\", 120 | \"relative_path\":\".\",\"links\":\"follow\", 121 | \"owner\":999,\"group\":999,\"mode\":420, 122 | \"checksum\":{\"type\":\"ctime\", 123 | \"value\":\"{ctime}2019-03-28 10:53:51 +0000\"},\"type\":\"directory\",\"destination\":null 124 | }, 125 | { 126 | \"path\":\"#{puppetserver_directory_path}\", 127 | \"relative_path\":\"#{fake_file_path}\",\"links\":\"follow\", 128 | \"owner\":999,\"group\":999,\"mode\":420, 129 | \"checksum\":{\"type\":\"md5\", 130 | \"value\":\"{md5}acbd18db4cc2f85cedef654fccc4a4d8\"},\"type\":\"file\",\"destination\":null 131 | } 132 | ]", headers: { content_type: 'application/json' }) 133 | stub_request(:get, "https://localhost:9999/puppet/v3/file_metadata/plugins") 134 | .to_return(status: 200, body: "{ 135 | \"path\":\"#{fake_file_path}\", 136 | \"relative_path\":null,\"links\":\"follow\", 137 | \"owner\":999,\"group\":999,\"mode\":420, 138 | \"checksum\":{\"type\":\"md5\", 139 | \"value\":\"{md5}acbd18db4cc2f85cedef654fccc4a4d8\" 140 | },\"type\":\"file\",\"destination\":null}", headers: { content_type: 'application/json' }) 141 | stub_request(:get, "https://localhost:9999/puppet/v3/file_content/plugins/fake_file.rb?environment=production") 142 | .to_return(status: 200, body: "foo", headers: {}) 143 | 144 | expect(ACE::ForkUtil).not_to have_received(:isolate) 145 | 146 | result = plugin_cache.sync_core('production') 147 | folder_size = Dir[File.join(result, '**', '*')].count { |file| File.file?(file) } 148 | expect(folder_size).to eq 1 149 | expect(result).to be_a(String) 150 | end 151 | 152 | context 'when do_purge is false' do 153 | let(:plugin_cache) { described_class.new('/tmp/environment_cache', do_purge: false) } 154 | 155 | it 'will not set up purge timer' do 156 | expect(plugin_cache.instance_variable_get(:@purge)).to be_nil 157 | end 158 | end 159 | 160 | context 'when do_purge is true' do 161 | let(:plugin_cache) { described_class.new('/tmp/environment_cache', do_purge: true) } 162 | 163 | it 'will create and run the purge timer' do 164 | expect(plugin_cache.instance_variable_get(:@purge)).to be_a(Concurrent::TimerTask) 165 | end 166 | end 167 | 168 | context 'when do_purge is true and cache_dir_mutex is specified' do 169 | let(:other_mutex) { spy('other_mutex') } # rubocop:disable RSpec/VerifiedDoubles 170 | let(:plugin_cache) do 171 | described_class.new('/tmp/environment_cache', 172 | purge_interval: 1, 173 | purge_timeout: 1, 174 | purge_ttl: 1, 175 | cache_dir_mutex: other_mutex, 176 | do_purge: true) 177 | end 178 | 179 | it 'will create and run the purge timer' do 180 | expect(plugin_cache.instance_variable_get(:@purge)).to be_a(Concurrent::TimerTask) 181 | expect(plugin_cache.instance_variable_get(:@cache_dir_mutex)).to eq(other_mutex) 182 | 183 | plugin_cache 184 | sleep 2 # allow time for the purge timer to fire 185 | expect(other_mutex).to have_received(:with_write_lock).at_least(:once) 186 | end 187 | end 188 | end 189 | 190 | describe '#with_synced_libdir_core' do 191 | before do 192 | allow(FileUtils).to receive(:remove_dir) 193 | end 194 | 195 | it 'calls remove_dir after yielding' do 196 | allow(plugin_cache).to receive(:sync_core).with('production').and_return('/tmp/foo/blah/plugins') 197 | expect(ACE::ForkUtil).not_to have_received(:isolate) 198 | expect { |b| plugin_cache.with_synced_libdir_core('production', &b) }.to yield_with_no_args 199 | expect(FileUtils).to have_received(:remove_dir).with('/tmp/foo/blah/plugins') 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - \(PE-31689\) remove unnecessary fork in run_task endpoint 6 | 7 | All significant changes to this repo will be summarized in this file. 8 | 9 | ## [v1.2.4](https://github.com/puppetlabs/ace/tree/v1.2.4) (2021-04-28) 10 | 11 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.2.3...v1.2.4) 12 | 13 | - regenerate test certs and add scripts to aid in regeneration 14 | 15 | ## [v1.2.3](https://github.com/puppetlabs/ace/tree/v1.2.3) (2021-04-27) 16 | 17 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.2.2...v1.2.3) 18 | 19 | - \(PE-31688\) use internal puppet client's http pool for pluginsync 20 | 21 | 22 | ## [v1.2.2](https://github.com/puppetlabs/ace/tree/v1.2.2) (2021-02-12) 23 | 24 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.2.1...v1.2.2) 25 | 26 | - \(PE-31081\) Allow bolt >= 2.9 27 | 28 | 29 | ## [v1.2.1](https://github.com/puppetlabs/ace/tree/v1.2.1) (2020-10-28) 30 | 31 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.2.0...v1.2.1) 32 | 33 | **Implemented enhancements:** 34 | 35 | - \(PE-30044\) remove harmful terminology from ace [\#79](https://github.com/puppetlabs/ace/pull/79) ([barriserloth](https://github.com/barriserloth)) 36 | 37 | 38 | ## [v1.2.0](https://github.com/puppetlabs/ace/tree/v1.2.0) (2020-06-11) 39 | 40 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.1.1...v1.2.0) 41 | 42 | **Implemented enhancements:** 43 | 44 | - \(PE-29007\) add a puma status endpoint [\#75](https://github.com/puppetlabs/ace/pull/75) ([donoghuc](https://github.com/donoghuc)) 45 | 46 | ## [v1.1.1](https://github.com/puppetlabs/ace/tree/v1.1.1) (2020-02-21) 47 | 48 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.1.0...v1.1.1) 49 | 50 | ## [v1.1.0](https://github.com/puppetlabs/ace/tree/v1.1.0) (2020-01-17) 51 | 52 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v1.0.0...v1.1.0) 53 | 54 | **Implemented enhancements:** 55 | 56 | - \(PE-27794\) add a puma status endpoint [\#65](https://github.com/puppetlabs/ace/pull/65) ([tkishel](https://github.com/tkishel)) 57 | 58 | **Fixed bugs:** 59 | 60 | - \(MODULES-10451\) snoop `certificate\_revocation` setting from puppet [\#66](https://github.com/puppetlabs/ace/pull/66) ([DavidS](https://github.com/DavidS)) 61 | 62 | **Merged pull requests:** 63 | 64 | - \(QENG-7501\) Minor formatting change to push tag in build pipeline [\#63](https://github.com/puppetlabs/ace/pull/63) ([cmccrisken-puppet](https://github.com/cmccrisken-puppet)) 65 | 66 | ## [v1.0.0](https://github.com/puppetlabs/ace/tree/v1.0.0) (2019-10-08) 67 | 68 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v0.10.0...v1.0.0) 69 | 70 | **Implemented enhancements:** 71 | 72 | - \(FM-8503\) implement transport loading if there is no device.rb shim [\#55](https://github.com/puppetlabs/ace/pull/55) ([Lavinia-Dan](https://github.com/Lavinia-Dan)) 73 | - \(FM-8485\) - Addition of CODEOWNERS file [\#53](https://github.com/puppetlabs/ace/pull/53) ([david22swan](https://github.com/david22swan)) 74 | - \(FM-8446\) remove remote-transport requirement [\#50](https://github.com/puppetlabs/ace/pull/50) ([DavidS](https://github.com/DavidS)) 75 | - \(PE-27029\) introduce enforce\_environment to support strict mode [\#49](https://github.com/puppetlabs/ace/pull/49) ([DavidS](https://github.com/DavidS)) 76 | - \(PE-27024\) return detailed results from `/execute\_catalog` [\#48](https://github.com/puppetlabs/ace/pull/48) ([DavidS](https://github.com/DavidS)) 77 | 78 | **Fixed bugs:** 79 | 80 | - \(FM-8566\) Add additional error handling for /run\_task [\#60](https://github.com/puppetlabs/ace/pull/60) ([da-ar](https://github.com/da-ar)) 81 | - \(FM-8497\) Ensure cross-process mutexing [\#59](https://github.com/puppetlabs/ace/pull/59) ([da-ar](https://github.com/da-ar)) 82 | - \(FM-8481\) Add missing headers for native extensions [\#51](https://github.com/puppetlabs/ace/pull/51) ([da-ar](https://github.com/da-ar)) 83 | - \\(PE-27024\\) return detailed results from `/execute\\_catalog` [\#48](https://github.com/puppetlabs/ace/pull/48) ([DavidS](https://github.com/DavidS)) 84 | 85 | **Merged pull requests:** 86 | 87 | - \(PE-27346\) Release prep for 1.0.0 [\#62](https://github.com/puppetlabs/ace/pull/62) ([sheenaajay](https://github.com/sheenaajay)) 88 | - \(maint\) rubocop fixes for RSpec/EmptyLineAfterExample [\#61](https://github.com/puppetlabs/ace/pull/61) ([da-ar](https://github.com/da-ar)) 89 | - \(FM-8496\) Add support for Puppet debug flags during /execute\_catalog [\#58](https://github.com/puppetlabs/ace/pull/58) ([david22swan](https://github.com/david22swan)) 90 | - \(maint\) Do not follow spec test found in `Volumes` [\#56](https://github.com/puppetlabs/ace/pull/56) ([da-ar](https://github.com/da-ar)) 91 | - \(maint\) various cleanups [\#52](https://github.com/puppetlabs/ace/pull/52) ([DavidS](https://github.com/DavidS)) 92 | - \(maint\) using the CA\_ALLOW\_SUBJECT\_ALT\_NAMES env variable for new doc… [\#47](https://github.com/puppetlabs/ace/pull/47) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 93 | 94 | ## [v0.10.0](https://github.com/puppetlabs/ace/tree/v0.10.0) (2019-07-25) 95 | 96 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v0.9.1...v0.10.0) 97 | 98 | **Merged pull requests:** 99 | 100 | - fixed rubocop offenses [\#46](https://github.com/puppetlabs/ace/pull/46) ([Lavinia-Dan](https://github.com/Lavinia-Dan)) 101 | - \(FM-8106\) Workaround license\_finder issue [\#45](https://github.com/puppetlabs/ace/pull/45) ([DavidS](https://github.com/DavidS)) 102 | - \(FM-7953\) Add acceptance tests to travis [\#43](https://github.com/puppetlabs/ace/pull/43) ([da-ar](https://github.com/da-ar)) 103 | - \(maint\) making it clear on order of running the containers [\#42](https://github.com/puppetlabs/ace/pull/42) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 104 | - \(FM-7954\) plugin cache purge for stale environments [\#41](https://github.com/puppetlabs/ace/pull/41) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 105 | - \(maint\) fixing up the docker setup for executing catalogs [\#40](https://github.com/puppetlabs/ace/pull/40) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 106 | - \(maint\) Docker doc update [\#39](https://github.com/puppetlabs/ace/pull/39) ([willmeek](https://github.com/willmeek)) 107 | - \(FM-7927\) Update developer docs [\#38](https://github.com/puppetlabs/ace/pull/38) ([DavidS](https://github.com/DavidS)) 108 | - \(FM-7975\) Remove mock responses from /execute\_catalog endpoint [\#37](https://github.com/puppetlabs/ace/pull/37) ([da-ar](https://github.com/da-ar)) 109 | 110 | ## [v0.9.1](https://github.com/puppetlabs/ace/tree/v0.9.1) (2019-04-16) 111 | 112 | [Full Changelog](https://github.com/puppetlabs/ace/compare/v0.9.0...v0.9.1) 113 | 114 | **Fixed bugs:** 115 | 116 | - \(maint\) remove load\_config parameter [\#34](https://github.com/puppetlabs/ace/pull/34) ([da-ar](https://github.com/da-ar)) 117 | 118 | **Merged pull requests:** 119 | 120 | - \(maint\) Release prep for v0.9.1 [\#36](https://github.com/puppetlabs/ace/pull/36) ([willmeek](https://github.com/willmeek)) 121 | - \(FM-7927\) Docs review [\#35](https://github.com/puppetlabs/ace/pull/35) ([clairecadman](https://github.com/clairecadman)) 122 | 123 | ## [v0.9.0](https://github.com/puppetlabs/ace/tree/v0.9.0) (2019-04-16) 124 | 125 | [Full Changelog](https://github.com/puppetlabs/ace/compare/0.1.0...v0.9.0) 126 | 127 | **Implemented enhancements:** 128 | 129 | - \(FM-7922\) running the configuration to apply catalog to transport [\#30](https://github.com/puppetlabs/ace/pull/30) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 130 | - \(FM-7893\) construct the trusted facts required for catalog requests [\#26](https://github.com/puppetlabs/ace/pull/26) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 131 | - \(FM-7886\) initialise remote transport for catalog apply [\#25](https://github.com/puppetlabs/ace/pull/25) ([willmeek](https://github.com/willmeek)) 132 | - \(FM-7883\) execute plugin sync from a puppetserver [\#20](https://github.com/puppetlabs/ace/pull/20) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 133 | - \(FM-7826\) first pass of execute catalog docs and mock API endpoint [\#16](https://github.com/puppetlabs/ace/pull/16) ([DavidS](https://github.com/DavidS)) 134 | - Utilities for environment isolation per request [\#12](https://github.com/puppetlabs/ace/pull/12) ([willmeek](https://github.com/willmeek)) 135 | 136 | **Fixed bugs:** 137 | 138 | - \(FM-7959\) Handle CA certificate bundles and CRL bundles [\#33](https://github.com/puppetlabs/ace/pull/33) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 139 | 140 | **Merged pull requests:** 141 | 142 | - \(FM-7927\) update developer docs pre-release [\#32](https://github.com/puppetlabs/ace/pull/32) ([DavidS](https://github.com/DavidS)) 143 | - \(FM-7952\) allow reports from ACE in testing auth.conf [\#31](https://github.com/puppetlabs/ace/pull/31) ([DavidS](https://github.com/DavidS)) 144 | - \(maint\) adding block passthrough to the libdir core method [\#28](https://github.com/puppetlabs/ace/pull/28) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 145 | - \(maint\) additional admin endpoints from bolt-server [\#27](https://github.com/puppetlabs/ace/pull/27) ([DavidS](https://github.com/DavidS)) 146 | - \(FM-7882\) Add client for catalog retrieval [\#24](https://github.com/puppetlabs/ace/pull/24) ([da-ar](https://github.com/da-ar)) 147 | - \(maint\) Update to bolt 1.15.0 [\#22](https://github.com/puppetlabs/ace/pull/22) ([DavidS](https://github.com/DavidS)) 148 | - \(maint\) adding instructions on generating aceserver cert on docker co… [\#21](https://github.com/puppetlabs/ace/pull/21) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 149 | - \(maint\) adjusted the development puppetserver to no longer use custom certs [\#19](https://github.com/puppetlabs/ace/pull/19) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 150 | - \(FM-7869\) Implement a Remote Task [\#18](https://github.com/puppetlabs/ace/pull/18) ([da-ar](https://github.com/da-ar)) 151 | - \(maint\) copy edit API docs [\#15](https://github.com/puppetlabs/ace/pull/15) ([DavidS](https://github.com/DavidS)) 152 | - \(FM-7872\) adding in the acls for the ace service [\#14](https://github.com/puppetlabs/ace/pull/14) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 153 | - \(maint\) work on adding the required files for the vanagon build [\#13](https://github.com/puppetlabs/ace/pull/13) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 154 | - \(maint\) the required docker changes for puppetserver work [\#11](https://github.com/puppetlabs/ace/pull/11) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 155 | - Update README.md [\#10](https://github.com/puppetlabs/ace/pull/10) ([willmeek](https://github.com/willmeek)) 156 | - \(maint\) reworking of the configuration [\#5](https://github.com/puppetlabs/ace/pull/5) ([Thomas-Franklin](https://github.com/Thomas-Franklin)) 157 | - Update JSONSchema, and mock endpoint [\#4](https://github.com/puppetlabs/ace/pull/4) ([da-ar](https://github.com/da-ar)) 158 | 159 | ## [0.1.0](https://github.com/puppetlabs/ace/tree/0.1.0) (2018-11-30) 160 | 161 | [Full Changelog](https://github.com/puppetlabs/ace/compare/bb49822f5d3b0dc47e8c10cadb3b4ea1c507d9ef...0.1.0) 162 | 163 | **Implemented enhancements:** 164 | 165 | - \(PE-25514\) Add docker support for ACE [\#3](https://github.com/puppetlabs/ace/pull/3) ([da-ar](https://github.com/da-ar)) 166 | - \(PE-25508\) Add JSON Schema and validation example [\#2](https://github.com/puppetlabs/ace/pull/2) ([da-ar](https://github.com/da-ar)) 167 | 168 | **Merged pull requests:** 169 | 170 | - \(PE-25509\) first fake endpoint; rubocop [\#1](https://github.com/puppetlabs/ace/pull/1) ([DavidS](https://github.com/DavidS)) 171 | 172 | 173 | 174 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 175 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lib/ace/transport_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ace/file_mutex' 4 | require 'ace/error' 5 | require 'ace/fork_util' 6 | require 'ace/puppet_util' 7 | require 'ace/configurer' 8 | require 'ace/plugin_cache' 9 | require 'bolt_server/file_cache' 10 | require 'bolt/executor' 11 | require 'bolt/inventory' 12 | require 'bolt/target' 13 | require 'bolt/task/puppet_server' 14 | require 'json-schema' 15 | require 'json' 16 | require 'sinatra' 17 | require 'puppet/util/network_device/base' 18 | 19 | module ACE 20 | class TransportApp < Sinatra::Base 21 | def initialize(config = nil) 22 | @logger = Logging.logger[self] 23 | @config = config 24 | @executor = Bolt::Executor.new(0) 25 | tasks_cache_dir = File.join(@config['cache-dir'], 'tasks') 26 | @mutex = ACE::FileMutex.new(Tempfile.new('ace.lock')) 27 | @file_cache = BoltServer::FileCache.new(@config.data.merge('cache-dir' => tasks_cache_dir), 28 | cache_dir_mutex: @mutex, do_purge: false).setup 29 | environments_cache_dir = File.join(@config['cache-dir'], 'environment_cache') 30 | @plugins_mutex = ACE::FileMutex.new(Tempfile.new('ace.plugins.lock')) 31 | @plugins = ACE::PluginCache.new(environments_cache_dir, 32 | cache_dir_mutex: @plugins_mutex, do_purge: false).setup 33 | 34 | @schemas = { 35 | "run_task" => JSON.parse(File.read(File.join(__dir__, 'schemas', 'ace-run_task.json'))), 36 | "execute_catalog" => JSON.parse(File.read(File.join(__dir__, 'schemas', 'ace-execute_catalog.json'))) 37 | } 38 | shared_schema = JSON::Schema.new(JSON.parse(File.read(File.join(__dir__, 'schemas', 'task.json'))), 39 | Addressable::URI.parse("file:task")) 40 | JSON::Validator.add_schema(shared_schema) 41 | 42 | ACE::PuppetUtil.init_global_settings(config['ssl-ca-cert'], 43 | config['ssl-ca-crls'], 44 | config['ssl-key'], 45 | config['ssl-cert'], 46 | config['cache-dir'], 47 | URI.parse(config['puppet-server-uri'])) 48 | 49 | ace_pid = Process.pid 50 | @logger.info "ACE started: #{ace_pid}" 51 | fork do 52 | # :nocov: 53 | # FileCache and PluginCache cleanup timer started in a seperate fork 54 | # so that there is only a single timer responsible for purging old files 55 | @logger.info "FileCache process started: #{Process.pid}" 56 | @fc_purge = BoltServer::FileCache.new(@config.data.merge('cache-dir' => tasks_cache_dir), 57 | cache_dir_mutex: @mutex, 58 | do_purge: true) 59 | 60 | @pc_purge = ACE::PluginCache.new(environments_cache_dir, 61 | cache_dir_mutex: @plugins_mutex, do_purge: true) 62 | loop do 63 | begin 64 | # is the parent process alibve 65 | Process.getpgid(ace_pid) 66 | sleep 10 # how often to check if parent process is alive 67 | rescue Interrupt 68 | # handle ctrl-c event 69 | break 70 | rescue StandardError 71 | # parent is no longer alive 72 | break 73 | end 74 | end 75 | @logger.info "FileCache process ended" 76 | # :nocov: 77 | end 78 | 79 | super(nil) 80 | end 81 | 82 | # Initialises the puppet target. 83 | # @param certname The certificate name of the target. 84 | # @param transport The transport provider of the target. 85 | # @param target Target connection hash or legacy connection URI 86 | # @return [Puppet device instance] Returns Puppet device instance 87 | # @raise [puppetlabs/ace/invalid_param] If nil parameter or no connection detail found 88 | # @example Connect to device. 89 | # init_puppet_target('test_device.domain.com', 'panos', JSON.parse("target":{ 90 | # "remote-transport":"panos", 91 | # "host":"fw.example.net", 92 | # "user":"foo", 93 | # "password":"wibble" 94 | # }) ) => panos.device 95 | def self.init_puppet_target(certname, transport, target) 96 | unless target 97 | raise ACE::Error.new("There was an error parsing the Puppet target. 'target' not found", 98 | 'puppetlabs/ace/invalid_param') 99 | end 100 | unless certname 101 | raise ACE::Error.new("There was an error parsing the Puppet compiler details. 'certname' not found", 102 | 'puppetlabs/ace/invalid_param') 103 | end 104 | unless transport 105 | raise ACE::Error.new("There was an error parsing the Puppet target. 'transport' not found", 106 | 'puppetlabs/ace/invalid_param') 107 | end 108 | 109 | if target['uri'] 110 | if target['uri'] =~ URI::DEFAULT_PARSER.make_regexp 111 | # Correct URL 112 | url = target['uri'] 113 | else 114 | raise ACE::Error.new("There was an error parsing the URI of the Puppet target", 115 | 'puppetlabs/ace/invalid_param') 116 | end 117 | else 118 | url = Hash[target.map { |(k, v)| [k.to_sym, v] }] 119 | url.delete(:"remote-transport") 120 | end 121 | 122 | device_struct = Struct.new(:provider, :url, :name, :options) 123 | type = target['remote-transport'] 124 | # Return device 125 | begin 126 | require 'puppet/resource_api/transport' 127 | transport = Puppet::ResourceApi::Transport.connect(type, url) 128 | Puppet::ResourceApi::Transport.inject_device(type, transport) 129 | rescue Puppet::DevError, LoadError => e 130 | raise e unless e.message.include?("Transport for `#{type}` not registered with") || e.class == LoadError 131 | # fallback to puppet device if there's no transport 132 | Puppet::Util::NetworkDevice.init(device_struct.new(transport, 133 | url, 134 | certname, 135 | {})) 136 | end 137 | end 138 | 139 | def scrub_stack_trace(result) 140 | if result.dig('value', '_error', 'details', 'stack_trace') 141 | result['value']['_error']['details'].reject! { |k| k == 'stack_trace' } 142 | end 143 | if result.dig('value', '_error', 'details', 'backtrace') 144 | result['value']['_error']['details'].reject! { |k| k == 'backtrace' } 145 | end 146 | result 147 | end 148 | 149 | def error_result(error) 150 | { 151 | 'status' => 'failure', 152 | 'value' => { '_error' => error.to_h } 153 | } 154 | end 155 | 156 | def validate_schema(schema, body) 157 | schema_error = JSON::Validator.fully_validate(schema, body) 158 | if schema_error.any? 159 | raise ACE::Error.new("There was an error validating the request body.", 160 | 'puppetlabs/ace/schema-error', 161 | schema_error: schema_error.first) 162 | end 163 | end 164 | 165 | def nest_metrics(metrics) 166 | Hash[metrics.fetch('resources', {}).values.map do |name, _human_name, value| 167 | [name, value] 168 | end] 169 | end 170 | 171 | # returns a hash of trusted facts that will be used 172 | # to request a catalog for the target 173 | def self.trusted_facts(certname) 174 | # if the certname is a valid FQDN, it will split 175 | # it in to the correct hostname.domain format 176 | # otherwise hostname will be the certname and domain 177 | # will be empty 178 | hostname, domain = certname.split('.', 2) 179 | trusted_facts = { 180 | "authenticated": "remote", 181 | "extensions": {}, 182 | "certname": certname, 183 | "hostname": hostname 184 | } 185 | trusted_facts[:domain] = domain if domain 186 | trusted_facts 187 | end 188 | 189 | get "/" do 190 | 200 191 | end 192 | 193 | post "/check" do 194 | [200, 'OK'] 195 | end 196 | 197 | # :nocov: 198 | if ENV['RACK_ENV'] == 'dev' 199 | get '/admin/gc' do 200 | GC.start 201 | 200 202 | end 203 | end 204 | 205 | get '/admin/gc_stat' do 206 | [200, GC.stat.to_json] 207 | end 208 | 209 | get '/admin/status' do 210 | stats = Puma.stats 211 | [200, stats.is_a?(Hash) ? stats.to_json : stats] 212 | end 213 | # :nocov: 214 | 215 | # run this with "curl -X POST http://0.0.0.0:44633/run_task -d '{}'" 216 | post '/run_task' do 217 | content_type :json 218 | 219 | begin 220 | body = JSON.parse(request.body.read) 221 | validate_schema(@schemas["run_task"], body) 222 | 223 | inventory = Bolt::Inventory.empty 224 | local_data = { 'name' => 'localhost', 225 | 'config' => { 'transport' => 'local', 226 | 'local' => { 'interpreters' => { 227 | 'rb' => ['/opt/puppetlabs/puppet/bin/ruby', '-r', 'bolt'] 228 | } } } } 229 | Bolt::Target.from_hash(local_data, inventory) 230 | target_data = { 231 | 'name' => body['target']['host'] || body['target']['name'] || 'remote', 232 | 'config' => { 233 | 'transport' => 'remote', 234 | 'remote' => body['target'] 235 | } 236 | } 237 | target = [Bolt::Target.from_hash(target_data, inventory)] 238 | rescue ACE::Error => e 239 | return [400, error_result(e).to_json] 240 | rescue JSON::ParserError => e 241 | request_error = ACE::Error.new(e.message, 242 | 'puppetlabs/ace/request_exception', 243 | class: e.class, backtrace: e.backtrace).to_h 244 | 245 | return [400, error_result(request_error).to_json] 246 | rescue StandardError => e 247 | request_error = ACE::Error.new(e.message, 248 | 'puppetlabs/ace/execution_exception', 249 | class: e.class, backtrace: e.backtrace).to_h 250 | 251 | return [500, error_result(request_error).to_json] 252 | end 253 | 254 | begin 255 | task_data = body['task'] 256 | task = Bolt::Task::PuppetServer.new(task_data['name'], task_data['metadata'], task_data['files'], @file_cache) 257 | 258 | parameters = body['parameters'] || {} 259 | ## When a positive timeout is specified execute the task in a thread, wait for timeout seconds and 260 | ## if the task is not done by the specified timeout attempt to kill the thread and move on 261 | results = if body['timeout'] && body['timeout'] > 0 262 | task_thread = Thread.new { @executor.run_task(target, task, parameters) } 263 | if task_thread.join(body['timeout']).nil? 264 | task_thread.kill 265 | raise ACE::Error.new("Task execution on #{target.first.safe_name} timed " \ 266 | "out after #{body['timeout']} seconds", 267 | 'puppetlabs/ace/timeout_exception') 268 | else 269 | task_thread.value 270 | end 271 | else 272 | @executor.run_task(target, task, parameters) 273 | end 274 | # Since this will only be on one node we can just return the first result 275 | result = results.first 276 | # Unwrap _sensitive output (orchestrator will handle obfuscating it from the result) 277 | if result.value.is_a?(Hash) && result.value.key?('_sensitive') 278 | result.value['_sensitive'] = result.value['_sensitive'].unwrap 279 | end 280 | result = scrub_stack_trace(result.to_data) 281 | 282 | [200, result.to_json] 283 | rescue Exception => e # rubocop:disable Lint/RescueException 284 | # handle all the things and make it obvious what happened 285 | process_error = { 286 | "target": target.first.name, 287 | "action": nil, 288 | "object": nil, 289 | "status": "failure", 290 | "value": { 291 | "_error": ACE::Error.new(e.message, 292 | 'puppetlabs/ace/processing_exception', 293 | class: e.class, backtrace: e.backtrace).to_h 294 | } 295 | } 296 | return [500, process_error.to_json] 297 | end 298 | end 299 | 300 | post '/execute_catalog' do 301 | content_type :json 302 | 303 | begin 304 | body = JSON.parse(request.body.read) 305 | validate_schema(@schemas["execute_catalog"], body) 306 | 307 | environment = body['compiler']['environment'] 308 | enforce_environment = body['compiler']['enforce_environment'] 309 | if environment == '' && !enforce_environment 310 | environment = 'production' 311 | elsif environment == '' && enforce_environment 312 | raise ACE::Error.new('You MUST provide an `environment` when `enforce_environment` is set to true', 313 | 'puppetlabs/ace/execute_catalog') 314 | end 315 | certname = body['compiler']['certname'] 316 | trans_id = body['compiler']['transaction_uuid'] 317 | job_id = body['compiler']['job_id'] 318 | rescue ACE::Error => e 319 | request_error = { 320 | status: 'failure', 321 | result: { 322 | _error: e.to_h 323 | } 324 | } 325 | return [400, request_error.to_json] 326 | rescue StandardError => e 327 | request_error = { 328 | status: 'failure', 329 | result: { 330 | _error: ACE::Error.new(e.message, 331 | 'puppetlabs/ace/request_exception', 332 | class: e.class, backtrace: e.backtrace) 333 | } 334 | } 335 | return [400, request_error.to_json] 336 | end 337 | 338 | begin 339 | run_result = @plugins.with_synced_libdir(environment, enforce_environment, certname, body['timeout']) do 340 | ACE::TransportApp.init_puppet_target(certname, body['target']['remote-transport'], body['target']) 341 | 342 | # Apply compiler flags for Configurer 343 | Puppet.settings[:noop] = body['compiler']['noop'] || false 344 | # grab the current debug level 345 | current_log_level = Puppet.settings[:log_level] if body['compiler']['debug'] 346 | # apply debug level if its specified 347 | Puppet.settings[:log_level] = :debug if body['compiler']['debug'] 348 | Puppet.settings[:trace] = body['compiler']['trace'] || false 349 | Puppet.settings[:evaltrace] = body['compiler']['evaltrace'] || false 350 | 351 | configurer = ACE::Configurer.new(body['compiler']['transaction_uuid'], body['compiler']['job_id']) 352 | options = { transport_name: certname, 353 | environment: environment, 354 | network_device: true, 355 | pluginsync: false, 356 | trusted_facts: ACE::TransportApp.trusted_facts(certname) } 357 | configurer.run(options) 358 | # return logging level back to original 359 | Puppet.settings[:log_level] = current_log_level if body['compiler']['debug'] 360 | # `options[:report]` gets populated by configurer.run with the report of the run with a 361 | # Puppet::Transaction::Report instance 362 | # see https://github.com/puppetlabs/puppet/blob/c956ad95fcdd9aabb28e196b55d1f112b5944777/lib/puppet/configurer.rb#L211 363 | report = options[:report] 364 | # remember that this hash gets munged by fork's json serialising 365 | { 366 | 'time' => report.time, 367 | 'transaction_uuid' => trans_id, 368 | 'environment' => report.environment, 369 | 'status' => report.status, 370 | 'metrics' => nest_metrics(report.metrics), 371 | 'job_id' => job_id 372 | } 373 | end 374 | rescue ACE::Error => e 375 | process_error = { 376 | certname: certname, 377 | status: 'failure', 378 | result: { 379 | _error: e.to_h 380 | } 381 | } 382 | return [400, process_error.to_json] 383 | rescue StandardError => e 384 | process_error = { 385 | certname: certname, 386 | status: 'failure', 387 | result: { 388 | _error: ACE::Error.new(e.message, 389 | 'puppetlabs/ace/processing_exception', 390 | class: e.class, backtrace: e.backtrace).to_h 391 | } 392 | } 393 | return [500, process_error.to_json] 394 | else 395 | result = { 396 | certname: certname, 397 | status: run_result.delete('status'), 398 | result: run_result 399 | } 400 | [200, result.to_json] 401 | end 402 | end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /spec/unit/ace/transport_app_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ace/config' 5 | require 'ace/configurer' 6 | require 'ace/error' 7 | require 'ace/transport_app' 8 | require 'rack/test' 9 | require 'puppet/resource_api/transport' 10 | 11 | RSpec.describe ACE::TransportApp do 12 | include Rack::Test::Methods 13 | 14 | def app 15 | ACE::TransportApp.new(ACE::Config.new(base_config)) 16 | end 17 | 18 | let(:base_config) do 19 | { 20 | "puppet-server-uri" => "https://localhost:9999", 21 | "cache-dir" => "/tmp/base_config" 22 | } 23 | end 24 | let(:executor) { instance_double(Bolt::Executor, 'executor') } 25 | let(:file_cache) { instance_double(BoltServer::FileCache, 'file_cache') } 26 | let(:task_response) { instance_double(Bolt::ResultSet, 'task_response') } 27 | let(:plugins) { instance_double(ACE::PluginCache, 'plugin_cache') } 28 | let(:response) { 29 | obj = { "output" => "Hello!" } 30 | target = Bolt::Target.new('foo') 31 | Bolt::Result.for_task(target, obj.to_json, '', 0, 'atask', ['/do/not/print', 8]) 32 | } 33 | let(:configurer) { instance_double(ACE::Configurer, 'configurer') } 34 | 35 | let(:body) do 36 | { 37 | 'task': echo_task, 38 | 'target': connection_info, 39 | 'parameters': { "message": "Hello!" } 40 | } 41 | end 42 | let(:execute_catalog_body) do 43 | { 44 | "target": { 45 | "remote-transport": "panos", 46 | "host": "fw.example.net", 47 | "user": "foo", 48 | "password": "wibble" 49 | }, 50 | "compiler": { 51 | "certname": certname, 52 | "environment": "development", 53 | "enforce_environment": false, 54 | "transaction_uuid": "", 55 | "job_id": "" 56 | } 57 | } 58 | end 59 | let(:echo_task) do 60 | { 61 | 'name': 'sample::echo', 62 | 'metadata': { 63 | 'description': 'Echo a message', 64 | 'parameters': { 'message': 'Default message' } 65 | }, 66 | files: [{ 67 | filename: "echo.sh", 68 | sha256: "foo", 69 | uri: {} 70 | }] 71 | } 72 | end 73 | let(:connection_info) do 74 | { 75 | 'remote-transport': 'panos', 76 | 'address': 'hostname', 77 | 'username': 'user', 78 | 'password': 'password' 79 | } 80 | end 81 | 82 | before do 83 | allow(Bolt::Executor).to receive(:new).with(0).and_return(executor) 84 | allow(BoltServer::FileCache).to receive(:new).and_return(file_cache) 85 | allow(ACE::PluginCache).to receive(:new).and_return(plugins) 86 | allow(file_cache).to receive(:setup) 87 | allow(plugins).to receive(:setup).and_return(plugins) 88 | allow(ACE::PuppetUtil).to receive(:init_global_settings) 89 | end 90 | 91 | describe '/' do 92 | it 'responds ok' do 93 | get '/' 94 | expect(last_response).to be_ok 95 | expect(last_response.status).to eq(200) 96 | end 97 | end 98 | 99 | describe '#trusted_facts' do 100 | it 'correctly parses a valid fqdn' do 101 | expect(described_class.trusted_facts('foo.domain.com')).to eq(authenticated: "remote", 102 | certname: "foo.domain.com", 103 | domain: "domain.com", 104 | extensions: {}, 105 | hostname: "foo") 106 | end 107 | 108 | it 'correctly returns when cert does not contain a dot' do 109 | expect(described_class.trusted_facts('foodomaincom')).to eq(authenticated: "remote", 110 | certname: "foodomaincom", 111 | extensions: {}, 112 | hostname: "foodomaincom") 113 | end 114 | end 115 | 116 | ################ 117 | # Tasks Endpoint 118 | ################ 119 | describe '/run_task' do 120 | before do 121 | allow(executor).to receive(:run_task).with( 122 | match_array(instance_of(Bolt::Target)), 123 | kind_of(Bolt::Task), 124 | { "message" => "Hello!" } 125 | ).and_return(task_response) 126 | 127 | allow(task_response).to receive(:first).and_return(response) 128 | end 129 | 130 | it 'throws an ace/schema_error if the request is invalid' do 131 | post '/run_task', JSON.generate({}), 'CONTENT_TYPE' => 'text/json' 132 | 133 | expect(last_response.body).to match(%r{puppetlabs\/ace\/schema-error}) 134 | expect(last_response.status).to eq(400) 135 | end 136 | 137 | it 'throws an ace/request_exception if the JSON is invalid' do 138 | post '/run_task', 'not json', 'CONTENT_TYPE' => 'text/json' 139 | 140 | expect(last_response.body).to match(%r{puppetlabs\/ace\/request_exception}) 141 | expect(last_response.status).to eq(400) 142 | end 143 | 144 | it 'throws an ace/request_exception if the request is invalid JSON' do 145 | post '/run_task', '{ foo }', 'CONTENT_TYPE' => 'text/json' 146 | 147 | expect(last_response.body).to match(%r{puppetlabs\/ace\/request_exception}) 148 | expect(last_response.status).to eq(400) 149 | end 150 | 151 | context 'when Bolt::Target throws' do 152 | before do 153 | allow(Bolt::Target).to receive(:new).and_raise Bolt::ParseError 154 | end 155 | 156 | it 'will be caught and handled by ace/execution_exception' do 157 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 158 | 159 | expect(last_response.body).to match(%r{puppetlabs\/ace\/execution_exception}) 160 | expect(last_response.status).to eq(500) 161 | end 162 | end 163 | 164 | context 'when Bolt::Inventory throws' do 165 | before do 166 | allow(Bolt::Inventory).to receive(:empty).and_raise(StandardError.new('yeah right')) 167 | end 168 | 169 | it 'will be caught and handled by ace/processing_exception' do 170 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 171 | 172 | expect(last_response.body).to match(%r{puppetlabs\/ace\/execution_exception}) 173 | expect(last_response.status).to eq(500) 174 | end 175 | end 176 | 177 | context 'when the task executes cleanly' do 178 | it 'returns the output' do 179 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 180 | expect(last_response.errors).to match(/\A\Z/) 181 | expect(last_response).to be_ok 182 | expect(last_response.status).to eq(200) 183 | result = JSON.parse(last_response.body) 184 | expect(result).to include('status' => 'success') 185 | expect(result['value']['output']).to eq('Hello!') 186 | end 187 | end 188 | 189 | context 'when no remote-transport is specified' do 190 | let(:connection_info) do 191 | { 192 | 'address': 'hostname', 193 | 'username': 'user', 194 | 'password': 'password' 195 | } 196 | end 197 | 198 | it 'returns the output' do 199 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 200 | 201 | expect(last_response.errors).to match(/\A\Z/) 202 | expect(last_response).to be_ok 203 | expect(last_response.status).to eq(200) 204 | result = JSON.parse(last_response.body) 205 | expect(result).to include('status' => 'success') 206 | expect(result['value']['output']).to eq('Hello!') 207 | end 208 | end 209 | 210 | context 'when the task executed returns a `backtrace`' do 211 | let(:status) do 212 | { 213 | node: "fw.example.net", 214 | status: "failure", 215 | value: { 216 | '_error' => { 217 | 'msg' => 'Failed to open TCP connection to fw.example.net', 218 | 'kind' => 'module/unknown', 219 | 'details' => { 220 | 'class' => 'SocketError', 221 | 'backtrace' => [ 222 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:906:in `rescue in block in connect'", 223 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:903:in `block in connect'", 224 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/timeout.rb:93:in `block in timeout'", 225 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/timeout.rb:103:in `timeout'", 226 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:902:in `connect'", 227 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:887:in `do_start'", 228 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:882:in `start'", 229 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:608:in `start'" 230 | ] 231 | } 232 | } 233 | } 234 | } 235 | end 236 | let(:response) { 237 | obj = { 238 | '_error' => { 239 | 'msg' => 'Failed to open TCP connection to fw.example.net', 240 | 'kind' => 'module/unknown', 241 | 'details' => { 242 | 'class' => 'SocketError', 243 | 'backtrace' => [ 244 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:906:in `rescue in block in connect'", 245 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:903:in `block in connect'", 246 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/timeout.rb:93:in `block in timeout'", 247 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/timeout.rb:103:in `timeout'", 248 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:902:in `connect'", 249 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:887:in `do_start'", 250 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:882:in `start'", 251 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:608:in `start'" 252 | ] 253 | } 254 | } 255 | } 256 | target = Bolt::Target.new('foo') 257 | Bolt::Result.for_task(target, obj.to_json, '', 0, 'atask', ['/do/not/print', 8]) 258 | } 259 | 260 | it 'runs returns the output and removes the error' do 261 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 262 | 263 | expect(last_response.errors).to match(/\A\Z/) 264 | expect(last_response).to be_ok 265 | expect(last_response.status).to eq(200) 266 | result = JSON.parse(last_response.body) 267 | expect(result).to include('status' => 'failure') 268 | expect(result['value']['_error']).not_to have_key('backtrace') 269 | end 270 | end 271 | 272 | context 'when the task executed returns a `stack_trace`' do 273 | let(:response) { 274 | obj = { 275 | '_error' => { 276 | 'msg' => 'Failed to open TCP connection to fw.example.net', 277 | 'kind' => 'module/unknown', 278 | 'details' => { 279 | 'class' => 'SocketError', 280 | 'stack_trace' => [ 281 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:906:in `rescue in block in connect'", 282 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:903:in `block in connect'", 283 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/timeout.rb:93:in `block in timeout'", 284 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/timeout.rb:103:in `timeout'", 285 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:902:in `connect'", 286 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:887:in `do_start'", 287 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:882:in `start'", 288 | "/Users/foo/.rbenv/versions/2.4.1/lib/ruby/2.4.0/net/http.rb:608:in `start'" 289 | ] 290 | } 291 | } 292 | } 293 | target = Bolt::Target.new('foo') 294 | Bolt::Result.for_task(target, obj.to_json, '', 0, 'atask', ['/do/not/print', 8]) 295 | } 296 | 297 | it 'runs returns the output and removes the error' do 298 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 299 | 300 | expect(last_response.errors).to match(/\A\Z/) 301 | expect(last_response).to be_ok 302 | expect(last_response.status).to eq(200) 303 | result = JSON.parse(last_response.body) 304 | expect(result).to include('status' => 'failure') 305 | expect(result['value']['_error']).not_to have_key('stack_trace') 306 | end 307 | end 308 | 309 | context 'with tasks that have sensitive results' do 310 | let(:response) { 311 | obj = { "key" => "val", "_sensitive" => "hi" } 312 | target = Bolt::Target.new('foo') 313 | Bolt::Result.for_task(target, obj.to_json, '', 0, 'atask', ['/do/not/print', 8]) 314 | } 315 | 316 | it 'returns the _sensitive output' do 317 | post '/run_task', JSON.generate(body), 'CONTENT_TYPE' => 'text/json' 318 | expect(last_response.errors).to match(/\A\Z/) 319 | expect(last_response).to be_ok 320 | expect(last_response.status).to eq(200) 321 | result = JSON.parse(last_response.body) 322 | expect(result).to include('status' => 'success') 323 | expect(result['value']['key']).to eq('val') 324 | expect(result['value']['_sensitive']).to eq('hi') 325 | end 326 | end 327 | end 328 | 329 | describe '/check' do 330 | it 'calls the correct method' do 331 | post '/check', {}, 'CONTENT_TYPE' => 'text/json' 332 | 333 | expect(last_response.status).to eq(200) 334 | expect(last_response.body).to eq('OK') 335 | end 336 | end 337 | 338 | ################## 339 | # Catalog Endpoint 340 | ################## 341 | describe '/execute_catalog' do 342 | let(:certname) { 'fw.example.net' } 343 | let(:report) { 344 | OpenStruct.new(time: 'time', 345 | environment: 'some_env', 346 | status: 'unchanged', 347 | metrics: { "resources" => OpenStruct.new("values" => [ 348 | ["metric name", "The Human Readable Metric Name", 666] 349 | ]) }) 350 | } 351 | let(:psettings) { { log_level: :wibble } } 352 | 353 | before { 354 | allow(plugins).to receive(:with_synced_libdir).and_yield 355 | allow(described_class).to receive(:init_puppet_target) 356 | 357 | allow(Puppet).to receive(:settings).and_return(psettings) 358 | 359 | allow(ACE::Configurer).to receive(:new).and_return(configurer) 360 | allow(configurer).to receive(:run) { |options| options[:report] = report } 361 | } 362 | 363 | # rubocop:disable RSpec/MessageSpies 364 | describe 'success' do 365 | it 'returns 200 with success' do 366 | expect(psettings).to receive(:[]=).with(:noop, false) 367 | expect(psettings).not_to receive(:[]=).with(:log_level, :debug) 368 | expect(psettings).to receive(:[]=).with(:trace, false) 369 | expect(psettings).to receive(:[]=).with(:evaltrace, false) 370 | 371 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 372 | expect { |b| plugins.with_synced_libdir('development', false, certname, nil, &b) }.to yield_with_no_args 373 | 374 | expect(configurer).to have_received(:run) 375 | expect(last_response.errors).to match(/\A\Z/) 376 | expect(last_response).to be_ok 377 | expect(last_response.status).to eq(200) 378 | result = JSON.parse(last_response.body) 379 | expect(result).to eq('certname' => certname, 'status' => 'unchanged', 380 | "result" => { "environment" => "some_env", 381 | "job_id" => "", 382 | "metrics" => { "metric name" => 666 }, 383 | "time" => "time", 384 | "transaction_uuid" => "" }) 385 | end 386 | end 387 | 388 | context 'when given flag debug' do 389 | it 'returns 200 with success' do 390 | execute_catalog_body[:compiler][:debug] = true 391 | 392 | expect(psettings).to receive(:[]=).with(:noop, false) 393 | expect(psettings).to receive(:[]=).with(:log_level, :debug) 394 | expect(psettings).to receive(:[]=).with(:trace, false) 395 | expect(psettings).to receive(:[]=).with(:evaltrace, false) 396 | 397 | # handle the reset of log_level 398 | expect(psettings).to receive(:[]=).with(:log_level, :wibble) 399 | 400 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 401 | 402 | expect(configurer).to have_received(:run) 403 | expect(last_response.errors).to match(/\A\Z/) 404 | expect(last_response).to be_ok 405 | expect(last_response.status).to eq(200) 406 | end 407 | end 408 | 409 | context 'when given flag noop' do 410 | it 'returns 200 with success' do 411 | execute_catalog_body[:compiler][:noop] = true 412 | 413 | expect(psettings).to receive(:[]=).with(:noop, true) 414 | expect(psettings).not_to receive(:[]=).with(:log_level, :debug) 415 | expect(psettings).to receive(:[]=).with(:trace, false) 416 | expect(psettings).to receive(:[]=).with(:evaltrace, false) 417 | 418 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 419 | 420 | expect(configurer).to have_received(:run) 421 | expect(last_response.errors).to match(/\A\Z/) 422 | expect(last_response).to be_ok 423 | expect(last_response.status).to eq(200) 424 | end 425 | end 426 | 427 | context 'when given flag trace' do 428 | it 'returns 200 with success' do 429 | execute_catalog_body[:compiler][:trace] = true 430 | 431 | expect(psettings).to receive(:[]=).with(:noop, false) 432 | expect(psettings).not_to receive(:[]=).with(:log_level, :debug) 433 | expect(psettings).to receive(:[]=).with(:trace, true) 434 | expect(psettings).to receive(:[]=).with(:evaltrace, false) 435 | 436 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 437 | 438 | expect(configurer).to have_received(:run) 439 | expect(last_response.errors).to match(/\A\Z/) 440 | expect(last_response).to be_ok 441 | expect(last_response.status).to eq(200) 442 | end 443 | end 444 | 445 | context 'when given flag evaltrace' do 446 | it 'returns 200 with success' do 447 | execute_catalog_body[:compiler][:evaltrace] = true 448 | 449 | expect(psettings).to receive(:[]=).with(:noop, false) 450 | expect(psettings).not_to receive(:[]=).with(:log_level, :debug) 451 | expect(psettings).to receive(:[]=).with(:trace, false) 452 | expect(psettings).to receive(:[]=).with(:evaltrace, true) 453 | 454 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 455 | 456 | expect(configurer).to have_received(:run) 457 | expect(last_response.errors).to match(/\A\Z/) 458 | expect(last_response).to be_ok 459 | expect(last_response.status).to eq(200) 460 | end 461 | end 462 | # rubocop:enable RSpec/MessageSpies 463 | 464 | context 'when the schema is invalid' do 465 | let(:execute_catalog_body) do 466 | { 467 | "target": { 468 | "remote-transport": "panos", 469 | "host": "fw.example.net", 470 | "user": "foo", 471 | "password": "wibble" 472 | }, 473 | "compiler": { 474 | "certname": certname, 475 | "transaction_uuid": "", 476 | "job_id": "" 477 | } 478 | } 479 | end 480 | 481 | it 'returns 400 with _error' do 482 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 483 | expect { |b| plugins.with_synced_libdir('development', false, certname, nil, &b) }.to yield_with_no_args 484 | expect(last_response.status).to eq(400) 485 | result = JSON.parse(last_response.body) 486 | expect(result['status']).to eq('failure') 487 | expect(result['result']['_error']['msg']).to eq('There was an error validating the request body.') 488 | expect(result['result']['_error']['kind']).to eq('puppetlabs/ace/schema-error') 489 | expect(result['result']['_error']['details']['schema_error']).to match( 490 | %r{The property '#/compiler' did not contain a required property of 'environment' in schema} 491 | ) 492 | end 493 | end 494 | 495 | context 'when the JSON is badly formatted' do 496 | let(:bad_json) { 497 | '{ 498 | "target":{ 499 | "remote-transport":"panos" 500 | "host":"12345.delivery.puppetlabs.net" 501 | "user":"admin" 502 | "password":"admin" 503 | }, 504 | "compiler":{ 505 | "certname":"12345.delivery.puppetlabs.net" 506 | "environment":"production" 507 | "transaction_uuid":"981687ce-520e-11e9-8647-d663bd873d93" 508 | "job_id":"1" 509 | } 510 | }' 511 | } 512 | 513 | it 'returns 400 with _error' do 514 | post '/execute_catalog', bad_json, 'CONTENT_TYPE' => 'text/json' 515 | expect { |b| plugins.with_synced_libdir('development', false, certname, nil, &b) }.to yield_with_no_args 516 | expect(last_response.status).to eq(400) 517 | result = JSON.parse(last_response.body) 518 | expect(result['status']).to eq('failure') 519 | expect(result['result']['_error']['msg']).to match(/unexpected token at/) 520 | expect(result['result']['_error']['kind']).to eq('puppetlabs/ace/request_exception') 521 | expect(result['result']['_error']['details']['class']).to eq('JSON::ParserError') 522 | expect(result['result']['_error']['details']).to be_key('backtrace') 523 | end 524 | end 525 | 526 | context 'when the error is an ACE error' do 527 | let(:error) { ACE::Error.new("something", "something/darkside") } 528 | 529 | before { 530 | allow(ACE::Configurer).to receive(:new).and_raise(error) 531 | } 532 | 533 | it 'returns 400 with _error' do 534 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 535 | expect { |b| plugins.with_synced_libdir('development', false, certname, nil, &b) }.to yield_with_no_args 536 | expect(last_response.errors).to match(/\A\Z/) 537 | expect(last_response.status).to eq(400) 538 | result = JSON.parse(last_response.body) 539 | expect(result).to eq('certname' => 'fw.example.net', 'status' => 'failure', 540 | 'result' => { '_error' => { 'msg' => 'something', 541 | 'kind' => 'something/darkside', 'details' => {} } }) 542 | end 543 | end 544 | 545 | context 'when the error is an unknown error' do 546 | let(:error) { RuntimeError.new("unknown error") } 547 | 548 | before { 549 | allow(ACE::Configurer).to receive(:new).and_raise(error) 550 | } 551 | 552 | it 'returns 500 with _error' do 553 | post '/execute_catalog', JSON.generate(execute_catalog_body), 'CONTENT_TYPE' => 'text/json' 554 | expect { |b| plugins.with_synced_libdir('development', false, certname, nil, &b) }.to yield_with_no_args 555 | expect(last_response.errors).to match(/\A\Z/) 556 | expect(last_response.status).to eq(500) 557 | result = JSON.parse(last_response.body) 558 | expect(result['status']).to eq('failure') 559 | expect(result['result']['_error']['msg']).to eq('unknown error') 560 | expect(result['result']['_error']['kind']).to eq('puppetlabs/ace/processing_exception') 561 | expect(result['result']['_error']['details']['class']).to eq('RuntimeError') 562 | expect(result['result']['_error']['details']).to be_key('backtrace') 563 | end 564 | end 565 | end 566 | 567 | ################## 568 | # init_puppet_target function 569 | ################## 570 | describe 'init_puppet_target' do 571 | describe 'success with transport style connection info' do 572 | device_raw = '{ 573 | "target": { 574 | "remote-transport":"panos", 575 | "host":"fw.example.net", 576 | "user":"foo", 577 | "password":"wibble" 578 | }, 579 | "compiler": { 580 | "certname":"fw.example.net", 581 | "environment":"development", 582 | "transaction_uuid":"", 583 | "job_id":"" 584 | } 585 | }' 586 | device_json = JSON.parse(device_raw) 587 | test_hash = Hash[device_json['target'].map { |(k, v)| [k.to_sym, v] }] 588 | test_hash.delete(:"remote-transport") 589 | # Our actual function inits a device, mocking this out with a simple return string for the purposes of test 590 | it 'returns correct device' do 591 | allow(Puppet::Util::NetworkDevice).to receive(:init) do |params| 592 | expect(params[:provider]).to eq(device_json['target']['remote-transport']) 593 | expect(params[:url]).to eq(test_hash) 594 | expect(params[:name]).to eq(device_json['compiler']['certname']) 595 | expect(params[:options]).to eql({}) 596 | 'panos_device' 597 | end 598 | 599 | expect(described_class.init_puppet_target(device_json['compiler']['certname'], 600 | device_json['target']['remote-transport'], 601 | device_json['target'])).to match(/(panos_device)/) 602 | end 603 | end 604 | 605 | describe 'success with legacy style uri' do 606 | device_raw = '{ 607 | "target":{ 608 | "remote-transport":"f5", 609 | "uri":"https://foo:wibble@f5.example.net/" 610 | }, 611 | "compiler":{ 612 | "certname":"f5.example.net", 613 | "environment":"development", 614 | "transaction_uuid":"", 615 | "job_id":"" 616 | } 617 | }' 618 | device_json = JSON.parse(device_raw) 619 | # Our actual function inits a device, mocking this out with a simple return string for the purposes of test 620 | it 'returns correct device' do 621 | allow(Puppet::Util::NetworkDevice).to receive(:init) do |params| 622 | expect(params[:provider]).to eq(device_json['target']['remote-transport']) 623 | expect(params[:url]).to eq(device_json['target']['uri']) 624 | expect(params[:name]).to eq(device_json['compiler']['certname']) 625 | expect(params[:options]).to eql({}) 626 | 'f5_device' 627 | end 628 | 629 | expect(described_class.init_puppet_target(device_json['compiler']['certname'], 630 | device_json['target']['remote-transport'], 631 | device_json['target'])).to match(/(f5_device)/) 632 | end 633 | end 634 | 635 | describe 'success with transport connection info' do 636 | device_raw = '{ 637 | "target": { 638 | "remote-transport":"panos", 639 | "host":"fw.example.net", 640 | "user":"foo", 641 | "password":"wibble" 642 | }, 643 | "compiler": { 644 | "certname":"fw.example.net", 645 | "environment":"development", 646 | "transaction_uuid":"", 647 | "job_id":"" 648 | } 649 | }' 650 | device_json = JSON.parse(device_raw) 651 | test_hash = Hash[device_json['target'].map { |(k, v)| [k.to_sym, v] }] 652 | type = test_hash[:"remote-transport"] 653 | test_hash.delete(:"remote-transport") 654 | it 'returns correct transport' do 655 | allow(Puppet::ResourceApi::Transport).to receive(:connect) 656 | .with(type, test_hash).and_return(test_hash) 657 | allow(Puppet::ResourceApi::Transport).to receive(:inject_device) 658 | .with(type, test_hash).and_return('panos_device') 659 | expect(described_class.init_puppet_target(device_json['compiler']['certname'], 660 | device_json['target']['remote-transport'], 661 | device_json['target'])).to match(/(panos_device)/) 662 | end 663 | end 664 | 665 | describe 'raise error when transport not registered' do 666 | device_raw = '{ 667 | "target": { 668 | "remote-transport":"panos", 669 | "host":"fw.example.net", 670 | "user":"foo", 671 | "password":"wibble" 672 | }, 673 | "compiler": { 674 | "certname":"fw.example.net", 675 | "environment":"development", 676 | "transaction_uuid":"", 677 | "job_id":"" 678 | } 679 | }' 680 | device_json = JSON.parse(device_raw) 681 | test_hash = Hash[device_json['target'].map { |(k, v)| [k.to_sym, v] }] 682 | type = test_hash[:"remote-transport"] 683 | test_hash.delete(:"remote-transport") 684 | 685 | it 'raises the provided exception' do 686 | allow(Puppet::ResourceApi::Transport).to receive(:connect) 687 | .with(type, test_hash).and_raise("Transport for `#{type}` not registered with") 688 | expect { 689 | described_class.init_puppet_target(device_json['compiler']['certname'], 690 | device_json['target']['remote-transport'], 691 | device_json['target']) 692 | } .to raise_error "Transport for `#{type}` not registered with" 693 | end 694 | end 695 | 696 | # rubocop:disable RSpec/MessageSpies 697 | 698 | describe 'raise error when invalid uri supplied' do 699 | device_raw = '{ 700 | "target":{ 701 | "remote-transport":"f5", 702 | "uri":"£$ %^%£$@ ^£@£" 703 | }, 704 | "compiler":{ 705 | "certname":"f5.example.net", 706 | "environment":"development", 707 | "transaction_uuid":"", 708 | "job_id":"" 709 | } 710 | }' 711 | device_json = JSON.parse(device_raw) 712 | it 'throws error and returns nil device' do 713 | expect(Puppet::Util::NetworkDevice).not_to receive(:init) 714 | 715 | expect { 716 | described_class.init_puppet_target(device_json['compiler']['certname'], 717 | device_json['target']['remote-transport'], 718 | device_json['target']) 719 | } .to raise_error ACE::Error, /There was an error parsing the URI of the Puppet target/ 720 | end 721 | end 722 | 723 | describe 'raise error when json supplied does not contain target' do 724 | device_raw = '{ 725 | "compiler":{ 726 | "certname":"f5.example.net", 727 | "environment":"development", 728 | "transaction_uuid":"", 729 | "job_id":"" 730 | } 731 | }' 732 | device_json = JSON.parse(device_raw) 733 | it 'throws error and returns nil device' do 734 | expect(Puppet::Util::NetworkDevice).not_to receive(:init) 735 | 736 | expect { 737 | described_class.init_puppet_target(device_json['compiler']['certname'], 738 | 'cisco_ios', 739 | nil) 740 | } .to raise_error ACE::Error, /There was an error parsing the Puppet target. 'target' not found/ 741 | end 742 | end 743 | 744 | describe 'raise error when json supplied does not contain compiler certname' do 745 | device_raw = '{ 746 | "target": { 747 | "remote-transport":"panos", 748 | "host":"fw.example.net", 749 | "user":"foo", 750 | "password":"wibble" 751 | }, 752 | "compiler": { 753 | "environment":"development", 754 | "transaction_uuid":"", 755 | "job_id":"" 756 | } 757 | }' 758 | device_json = JSON.parse(device_raw) 759 | it 'throws error and returns nil device' do 760 | expect(Puppet::Util::NetworkDevice).not_to receive(:init) 761 | 762 | expect { 763 | described_class.init_puppet_target(nil, 764 | device_json['target']['remote-transport'], 765 | device_json['target']) 766 | } .to raise_error ACE::Error, /There was an error parsing the Puppet compiler details. 'certname' not found/ 767 | end 768 | end 769 | 770 | describe 'raise error when json supplied does not contain remote-transport' do 771 | device_raw = '{ 772 | "target":{ 773 | "uri":"https://foo:wibble@f5.example.com" 774 | }, 775 | "compiler": { 776 | "certname":"f5.example.net", 777 | "environment":"development", 778 | "transaction_uuid":"", 779 | "job_id":"" 780 | } 781 | }' 782 | device_json = JSON.parse(device_raw) 783 | it 'throws error and returns nil device' do 784 | expect(Puppet::Util::NetworkDevice).not_to receive(:init) 785 | 786 | expect { 787 | described_class.init_puppet_target(device_json['compiler']['certname'], 788 | nil, 789 | device_json['target']) 790 | } .to raise_error ACE::Error, /There was an error parsing the Puppet target. 'transport' not found/ 791 | end 792 | end 793 | # rubocop:enable RSpec/MessageSpies 794 | end 795 | end 796 | --------------------------------------------------------------------------------